From 0ed9a05d5fa00a776e3bfbd4f84ce3b0f85af42f Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 10:22:24 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(wallet):=20WalletLedger=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EB=A0=88=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LedgerType enum 정의 (ORDER_LOCK, ORDER_CANCEL_RELEASE, TRADE_BUY_QUOTE_SETTLE 등 7종) - WalletLedger 엔티티 추가: delta, balance_after, order_id, trade_id 포함 - WalletLedgerRepository 추가: 사용자/자산별 최신순 조회 --- .../coinflow/wallet/domain/LedgerType.java | 11 ++++ .../coinflow/wallet/domain/WalletLedger.java | 59 +++++++++++++++++++ .../repository/WalletLedgerRepository.java | 13 ++++ 3 files changed, 83 insertions(+) create mode 100644 src/main/java/com/coinflow/wallet/domain/LedgerType.java create mode 100644 src/main/java/com/coinflow/wallet/domain/WalletLedger.java create mode 100644 src/main/java/com/coinflow/wallet/repository/WalletLedgerRepository.java diff --git a/src/main/java/com/coinflow/wallet/domain/LedgerType.java b/src/main/java/com/coinflow/wallet/domain/LedgerType.java new file mode 100644 index 0000000..4feabd2 --- /dev/null +++ b/src/main/java/com/coinflow/wallet/domain/LedgerType.java @@ -0,0 +1,11 @@ +package com.coinflow.wallet.domain; + +public enum LedgerType { + SEED_DEPOSIT, + ORDER_LOCK, + ORDER_CANCEL_RELEASE, + TRADE_BUY_QUOTE_SETTLE, + TRADE_BUY_BASE_CREDIT, + TRADE_SELL_BASE_SETTLE, + TRADE_SELL_QUOTE_CREDIT +} diff --git a/src/main/java/com/coinflow/wallet/domain/WalletLedger.java b/src/main/java/com/coinflow/wallet/domain/WalletLedger.java new file mode 100644 index 0000000..6431f9a --- /dev/null +++ b/src/main/java/com/coinflow/wallet/domain/WalletLedger.java @@ -0,0 +1,59 @@ +package com.coinflow.wallet.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@Entity +@NoArgsConstructor +@Table(name = "wallet_ledgers") +public class WalletLedger { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + private Long walletId; + private String asset; + + @Enumerated(EnumType.STRING) + private LedgerType type; + + private BigDecimal deltaAvailable; + private BigDecimal deltaLocked; + private BigDecimal availableBalanceAfter; + private BigDecimal lockedBalanceAfter; + + private Long orderId; + private Long tradeId; + + private LocalDateTime createdAt; + + public static WalletLedger create( + Wallet wallet, + LedgerType type, + BigDecimal deltaAvailable, + BigDecimal deltaLocked, + Long orderId, + Long tradeId + ) { + WalletLedger ledger = new WalletLedger(); + ledger.userId = wallet.getUserId(); + ledger.walletId = wallet.getId(); + ledger.asset = wallet.getAsset(); + ledger.type = type; + ledger.deltaAvailable = deltaAvailable; + ledger.deltaLocked = deltaLocked; + ledger.availableBalanceAfter = wallet.getAvailableBalance(); + ledger.lockedBalanceAfter = wallet.getLockedBalance(); + ledger.orderId = orderId; + ledger.tradeId = tradeId; + ledger.createdAt = LocalDateTime.now(); + return ledger; + } +} diff --git a/src/main/java/com/coinflow/wallet/repository/WalletLedgerRepository.java b/src/main/java/com/coinflow/wallet/repository/WalletLedgerRepository.java new file mode 100644 index 0000000..1af3757 --- /dev/null +++ b/src/main/java/com/coinflow/wallet/repository/WalletLedgerRepository.java @@ -0,0 +1,13 @@ +package com.coinflow.wallet.repository; + +import com.coinflow.wallet.domain.WalletLedger; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface WalletLedgerRepository extends JpaRepository { + + List findAllByUserIdOrderByCreatedAtDesc(Long userId); + + List findAllByUserIdAndAssetOrderByCreatedAtDesc(Long userId, String asset); +} From 77bb6da00a5e8adfe932bb0f01625322648c595f Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 10:22:36 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(order):=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1/=EC=B7=A8=EC=86=8C/=EC=B2=B4=EA=B2=B0=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9B=90=EC=9E=A5=20=EA=B8=B0=EB=A1=9D=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 주문 생성 시 자산 lock 후 ORDER_LOCK 원장 기록 - 주문 취소 시 lock 해제 후 ORDER_CANCEL_RELEASE 원장 기록 - 체결 정산 시 TRADE_BUY_QUOTE_SETTLE, TRADE_BUY_BASE_CREDIT, TRADE_SELL_BASE_SETTLE, TRADE_SELL_QUOTE_CREDIT 4종 원장 기록 --- .../coinflow/order/service/OrderService.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/main/java/com/coinflow/order/service/OrderService.java b/src/main/java/com/coinflow/order/service/OrderService.java index fe5cc94..361b874 100644 --- a/src/main/java/com/coinflow/order/service/OrderService.java +++ b/src/main/java/com/coinflow/order/service/OrderService.java @@ -20,7 +20,10 @@ import com.coinflow.order.repository.OrderSequenceRepository; import com.coinflow.trade.domain.Trade; import com.coinflow.trade.repository.TradeRepository; +import com.coinflow.wallet.domain.LedgerType; import com.coinflow.wallet.domain.Wallet; +import com.coinflow.wallet.domain.WalletLedger; +import com.coinflow.wallet.repository.WalletLedgerRepository; import com.coinflow.wallet.repository.WalletRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -40,6 +43,7 @@ public class OrderService { private final OrderSequenceRepository orderSequenceRepository; private final WalletRepository walletRepository; private final TradeRepository tradeRepository; + private final WalletLedgerRepository walletLedgerRepository; private final MatchingEngine matchingEngine; @Transactional @@ -103,6 +107,11 @@ public CreateOrderResponse createOrder(Long currentUserId, CreateOrderRequest re if (wallet.getAvailableBalance().compareTo(lockedAmount) < 0) throw new ApiException(ErrorCode.INSUFFICIENT_BALANCE); wallet.lock(lockedAmount); + walletLedgerRepository.save(WalletLedger.create( + wallet, LedgerType.ORDER_LOCK, + lockedAmount.negate(), lockedAmount, + null, null + )); // 10. order 저장 Order order = Order.create( @@ -133,6 +142,11 @@ public CancelOrderResponse cancelOrder(Long currentUserId, Long orderId) { Wallet wallet = walletRepository.findByUserIdAndAssetWithLock(currentUserId, order.getLockedAsset()) .orElseThrow(() -> new ApiException(ErrorCode.INSUFFICIENT_BALANCE)); wallet.unlock(releaseAmount); + walletLedgerRepository.save(WalletLedger.create( + wallet, LedgerType.ORDER_CANCEL_RELEASE, + releaseAmount, releaseAmount.negate(), + orderId, null + )); order.cancel(); matchingEngine.cancelOrder(order.getMarketSymbol(), order); @@ -189,6 +203,32 @@ private List settle(Market market, Order taker, List matchRe result.price(), result.quantity(), result.quoteAmount() ); tradeRepository.save(trade); + + Long buyOrderId = result.buyOrderId(); + Long sellOrderId = result.sellOrderId(); + Long tradeId = trade.getId(); + + walletLedgerRepository.save(WalletLedger.create( + buyerQuoteWallet, LedgerType.TRADE_BUY_QUOTE_SETTLE, + result.quoteAmount(), result.quoteAmount().negate(), + buyOrderId, tradeId + )); + walletLedgerRepository.save(WalletLedger.create( + buyerBaseWallet, LedgerType.TRADE_BUY_BASE_CREDIT, + result.quantity(), BigDecimal.ZERO, + buyOrderId, tradeId + )); + walletLedgerRepository.save(WalletLedger.create( + sellerBaseWallet, LedgerType.TRADE_SELL_BASE_SETTLE, + BigDecimal.ZERO, result.quantity().negate(), + sellOrderId, tradeId + )); + walletLedgerRepository.save(WalletLedger.create( + sellerQuoteWallet, LedgerType.TRADE_SELL_QUOTE_CREDIT, + result.quoteAmount(), BigDecimal.ZERO, + sellOrderId, tradeId + )); + trades.add(trade); } From bd4dfecc36dd2933720ed254df8b715ef5468994 Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 10:22:46 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(wallet):=20=EC=A7=80=EA=B0=91=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9B=90=EC=9E=A5=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/wallets: 사용자 전체 지갑 잔액 조회 - GET /api/v1/wallets/ledgers: 원장 내역 조회 (asset 쿼리 파라미터로 필터) --- .../coinflow/wallet/api/WalletController.java | 37 +++++++++++++++++++ .../wallet/dto/WalletLedgerResponse.java | 33 +++++++++++++++++ .../coinflow/wallet/dto/WalletResponse.java | 19 ++++++++++ .../wallet/service/WalletService.java | 35 ++++++++++++++++++ 4 files changed, 124 insertions(+) create mode 100644 src/main/java/com/coinflow/wallet/api/WalletController.java create mode 100644 src/main/java/com/coinflow/wallet/dto/WalletLedgerResponse.java create mode 100644 src/main/java/com/coinflow/wallet/dto/WalletResponse.java create mode 100644 src/main/java/com/coinflow/wallet/service/WalletService.java diff --git a/src/main/java/com/coinflow/wallet/api/WalletController.java b/src/main/java/com/coinflow/wallet/api/WalletController.java new file mode 100644 index 0000000..b8a2c83 --- /dev/null +++ b/src/main/java/com/coinflow/wallet/api/WalletController.java @@ -0,0 +1,37 @@ +package com.coinflow.wallet.api; + +import com.coinflow.wallet.dto.WalletLedgerResponse; +import com.coinflow.wallet.dto.WalletResponse; +import com.coinflow.wallet.service.WalletService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/wallets") +public class WalletController { + + private final WalletService walletService; + + @GetMapping + public List getWallets(@AuthenticationPrincipal Jwt jwt) { + Long userId = Long.parseLong(jwt.getSubject()); + return walletService.getWallets(userId); + } + + @GetMapping("/ledgers") + public List getLedgers( + @AuthenticationPrincipal Jwt jwt, + @RequestParam(required = false) String asset + ) { + Long userId = Long.parseLong(jwt.getSubject()); + return walletService.getLedgers(userId, asset); + } +} diff --git a/src/main/java/com/coinflow/wallet/dto/WalletLedgerResponse.java b/src/main/java/com/coinflow/wallet/dto/WalletLedgerResponse.java new file mode 100644 index 0000000..ac9632d --- /dev/null +++ b/src/main/java/com/coinflow/wallet/dto/WalletLedgerResponse.java @@ -0,0 +1,33 @@ +package com.coinflow.wallet.dto; + +import com.coinflow.wallet.domain.WalletLedger; + +import java.time.LocalDateTime; + +public record WalletLedgerResponse( + Long ledgerId, + String asset, + String type, + String deltaAvailable, + String deltaLocked, + String availableBalanceAfter, + String lockedBalanceAfter, + Long orderId, + Long tradeId, + LocalDateTime createdAt +) { + public static WalletLedgerResponse from(WalletLedger ledger) { + return new WalletLedgerResponse( + ledger.getId(), + ledger.getAsset(), + ledger.getType().name(), + ledger.getDeltaAvailable().toPlainString(), + ledger.getDeltaLocked().toPlainString(), + ledger.getAvailableBalanceAfter().toPlainString(), + ledger.getLockedBalanceAfter().toPlainString(), + ledger.getOrderId(), + ledger.getTradeId(), + ledger.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/coinflow/wallet/dto/WalletResponse.java b/src/main/java/com/coinflow/wallet/dto/WalletResponse.java new file mode 100644 index 0000000..b7757a4 --- /dev/null +++ b/src/main/java/com/coinflow/wallet/dto/WalletResponse.java @@ -0,0 +1,19 @@ +package com.coinflow.wallet.dto; + +import com.coinflow.wallet.domain.Wallet; + +public record WalletResponse( + Long walletId, + String asset, + String availableBalance, + String lockedBalance +) { + public static WalletResponse from(Wallet wallet) { + return new WalletResponse( + wallet.getId(), + wallet.getAsset(), + wallet.getAvailableBalance().toPlainString(), + wallet.getLockedBalance().toPlainString() + ); + } +} diff --git a/src/main/java/com/coinflow/wallet/service/WalletService.java b/src/main/java/com/coinflow/wallet/service/WalletService.java new file mode 100644 index 0000000..1866302 --- /dev/null +++ b/src/main/java/com/coinflow/wallet/service/WalletService.java @@ -0,0 +1,35 @@ +package com.coinflow.wallet.service; + +import com.coinflow.wallet.dto.WalletLedgerResponse; +import com.coinflow.wallet.dto.WalletResponse; +import com.coinflow.wallet.repository.WalletLedgerRepository; +import com.coinflow.wallet.repository.WalletRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class WalletService { + + private final WalletRepository walletRepository; + private final WalletLedgerRepository walletLedgerRepository; + + @Transactional(readOnly = true) + public List getWallets(Long userId) { + return walletRepository.findAllByUserId(userId) + .stream() + .map(WalletResponse::from) + .toList(); + } + + @Transactional(readOnly = true) + public List getLedgers(Long userId, String asset) { + var ledgers = (asset != null) + ? walletLedgerRepository.findAllByUserIdAndAssetOrderByCreatedAtDesc(userId, asset) + : walletLedgerRepository.findAllByUserIdOrderByCreatedAtDesc(userId); + return ledgers.stream().map(WalletLedgerResponse::from).toList(); + } +} From 1fa2b887158c19233c5f6aa1d3901117c60c2ead Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 10:36:38 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(order):=20ORDER=5FLOCK=20=EC=9B=90?= =?UTF-8?q?=EC=9E=A5=EC=97=90=20orderId=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주문 저장 후 원장을 기록하도록 순서 변경하여 order.getId()를 orderId로 전달 --- .../java/com/coinflow/order/service/OrderService.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/coinflow/order/service/OrderService.java b/src/main/java/com/coinflow/order/service/OrderService.java index 361b874..439389f 100644 --- a/src/main/java/com/coinflow/order/service/OrderService.java +++ b/src/main/java/com/coinflow/order/service/OrderService.java @@ -107,11 +107,6 @@ public CreateOrderResponse createOrder(Long currentUserId, CreateOrderRequest re if (wallet.getAvailableBalance().compareTo(lockedAmount) < 0) throw new ApiException(ErrorCode.INSUFFICIENT_BALANCE); wallet.lock(lockedAmount); - walletLedgerRepository.save(WalletLedger.create( - wallet, LedgerType.ORDER_LOCK, - lockedAmount.negate(), lockedAmount, - null, null - )); // 10. order 저장 Order order = Order.create( @@ -123,6 +118,12 @@ public CreateOrderResponse createOrder(Long currentUserId, CreateOrderRequest re ); orderRepository.save(order); + walletLedgerRepository.save(WalletLedger.create( + wallet, LedgerType.ORDER_LOCK, + lockedAmount.negate(), lockedAmount, + order.getId(), null + )); + // 11. 매칭 및 정산 List matchResults = matchingEngine.match(market, order); List trades = settle(market, order, matchResults); From 7c2e541e1a9efeb21ac2a43f64085bc82de4966d Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 10:36:44 +0900 Subject: [PATCH 5/5] =?UTF-8?q?test(wallet):=20=EC=A7=80=EA=B0=91=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=9B=90=EC=9E=A5=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/wallets, GET /api/v1/wallets/ledgers API 응답 검증 - 주문 생성 시 ORDER_LOCK 원장 기록 검증 - 주문 취소 시 ORDER_CANCEL_RELEASE 원장 기록 검증 - 체결 시 TRADE_BUY/SELL 4종 원장 기록 검증 --- .../com/coinflow/wallet/WalletApiTest.java | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 src/test/java/com/coinflow/wallet/WalletApiTest.java diff --git a/src/test/java/com/coinflow/wallet/WalletApiTest.java b/src/test/java/com/coinflow/wallet/WalletApiTest.java new file mode 100644 index 0000000..713fdc3 --- /dev/null +++ b/src/test/java/com/coinflow/wallet/WalletApiTest.java @@ -0,0 +1,252 @@ +package com.coinflow.wallet; + +import com.coinflow.auth.repository.UserRepository; +import com.coinflow.order.matching.MatchingEngine; +import com.coinflow.support.TestcontainersConfig; +import com.coinflow.wallet.domain.LedgerType; +import com.coinflow.wallet.domain.Wallet; +import com.coinflow.wallet.domain.WalletLedger; +import com.coinflow.wallet.repository.WalletLedgerRepository; +import com.coinflow.wallet.repository.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.http.*; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SuppressWarnings({"rawtypes", "unchecked"}) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(TestcontainersConfig.class) +class WalletApiTest { + + @Autowired private TestRestTemplate restTemplate; + @Autowired private UserRepository userRepository; + @Autowired private WalletRepository walletRepository; + @Autowired private WalletLedgerRepository walletLedgerRepository; + @Autowired private MatchingEngine matchingEngine; + + @BeforeEach + void setUp() { + matchingEngine.clearAll(); + } + + // ── WAL-001 지갑 조회 ───────────────────────────────────────────── + + @Test + void 지갑_조회_성공() { + String token = signupAndLogin("wallet001@example.com"); + + var response = getWallets(token); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List> wallets = (List>) response.getBody(); + assertThat(wallets).isNotEmpty(); + assertThat(wallets).allMatch(w -> + w.containsKey("walletId") && + w.containsKey("asset") && + w.containsKey("availableBalance") && + w.containsKey("lockedBalance") + ); + } + + @Test + void 지갑_조회_토큰_없음() { + var response = restTemplate.getForEntity("/api/v1/wallets", List.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + // ── WAL-002 원장 조회 ───────────────────────────────────────────── + + @Test + void 원장_조회_성공() { + String token = signupAndLogin("wallet002@example.com"); + depositKrw("wallet002@example.com", new BigDecimal("10000000")); + createOrder(token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + + var response = getLedgers(token, null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List> ledgers = (List>) response.getBody(); + assertThat(ledgers).isNotEmpty(); + assertThat(ledgers).allMatch(l -> + l.containsKey("ledgerId") && + l.containsKey("type") && + l.containsKey("deltaAvailable") && + l.containsKey("availableBalanceAfter") + ); + } + + @Test + void 원장_조회_asset_필터() { + String token = signupAndLogin("wallet003@example.com"); + depositKrw("wallet003@example.com", new BigDecimal("10000000")); + createOrder(token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + + var response = getLedgers(token, "KRW"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List> ledgers = (List>) response.getBody(); + assertThat(ledgers).isNotEmpty(); + assertThat(ledgers).allMatch(l -> "KRW".equals(l.get("asset"))); + } + + // ── LED-001 원장 기록 검증 ──────────────────────────────────────── + + @Test + void 주문_생성_시_ORDER_LOCK_원장_기록() { + String token = signupAndLogin("wallet004@example.com"); + depositKrw("wallet004@example.com", new BigDecimal("10000000")); + + createOrder(token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + + var user = userRepository.findByEmail("wallet004@example.com").orElseThrow(); + List ledgers = walletLedgerRepository.findAllByUserIdAndAssetOrderByCreatedAtDesc(user.getId(), "KRW"); + + assertThat(ledgers).hasSize(1); + WalletLedger ledger = ledgers.get(0); + assertThat(ledger.getType()).isEqualTo(LedgerType.ORDER_LOCK); + assertThat(ledger.getDeltaAvailable()).isEqualByComparingTo("-10000"); + assertThat(ledger.getDeltaLocked()).isEqualByComparingTo("10000"); + assertThat(ledger.getLockedBalanceAfter()).isEqualByComparingTo("10000"); + assertThat(ledger.getOrderId()).isNotNull(); + assertThat(ledger.getTradeId()).isNull(); + } + + @Test + void 주문_취소_시_ORDER_CANCEL_RELEASE_원장_기록() { + String token = signupAndLogin("wallet005@example.com"); + depositKrw("wallet005@example.com", new BigDecimal("10000000")); + + var createResponse = createOrder(token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + Long orderId = ((Number) createResponse.getBody().get("orderId")).longValue(); + cancelOrder(token, orderId); + + var user = userRepository.findByEmail("wallet005@example.com").orElseThrow(); + List ledgers = walletLedgerRepository.findAllByUserIdAndAssetOrderByCreatedAtDesc(user.getId(), "KRW"); + + assertThat(ledgers).hasSize(2); + WalletLedger cancelLedger = ledgers.get(0); // 최신순이므로 첫 번째가 취소 + assertThat(cancelLedger.getType()).isEqualTo(LedgerType.ORDER_CANCEL_RELEASE); + assertThat(cancelLedger.getDeltaAvailable()).isEqualByComparingTo("10000"); + assertThat(cancelLedger.getDeltaLocked()).isEqualByComparingTo("-10000"); + assertThat(cancelLedger.getAvailableBalanceAfter()).isEqualByComparingTo("10000000"); + assertThat(cancelLedger.getLockedBalanceAfter()).isEqualByComparingTo("0"); + } + + @Test + void 체결_시_정산_원장_4종_기록() { + String buyerToken = signupAndLogin("wallet006a@example.com"); + String sellerToken = signupAndLogin("wallet006b@example.com"); + depositKrw("wallet006a@example.com", new BigDecimal("10000000")); + depositBtc("wallet006b@example.com", new BigDecimal("0.001")); + + createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + + var buyer = userRepository.findByEmail("wallet006a@example.com").orElseThrow(); + var seller = userRepository.findByEmail("wallet006b@example.com").orElseThrow(); + + // 매수자: KRW ORDER_LOCK + TRADE_BUY_QUOTE_SETTLE, BTC TRADE_BUY_BASE_CREDIT + List buyerKrwLedgers = walletLedgerRepository + .findAllByUserIdAndAssetOrderByCreatedAtDesc(buyer.getId(), "KRW"); + List buyerBtcLedgers = walletLedgerRepository + .findAllByUserIdAndAssetOrderByCreatedAtDesc(buyer.getId(), "BTC"); + + assertThat(buyerKrwLedgers).hasSize(2); + assertThat(buyerKrwLedgers.get(0).getType()).isEqualTo(LedgerType.TRADE_BUY_QUOTE_SETTLE); + assertThat(buyerKrwLedgers.get(1).getType()).isEqualTo(LedgerType.ORDER_LOCK); + + assertThat(buyerBtcLedgers).hasSize(1); + assertThat(buyerBtcLedgers.get(0).getType()).isEqualTo(LedgerType.TRADE_BUY_BASE_CREDIT); + assertThat(buyerBtcLedgers.get(0).getDeltaAvailable()).isEqualByComparingTo("0.0001"); + + // 매도자: BTC ORDER_LOCK + TRADE_SELL_BASE_SETTLE, KRW TRADE_SELL_QUOTE_CREDIT + List sellerBtcLedgers = walletLedgerRepository + .findAllByUserIdAndAssetOrderByCreatedAtDesc(seller.getId(), "BTC"); + List sellerKrwLedgers = walletLedgerRepository + .findAllByUserIdAndAssetOrderByCreatedAtDesc(seller.getId(), "KRW"); + + assertThat(sellerBtcLedgers).hasSize(2); + assertThat(sellerBtcLedgers.get(0).getType()).isEqualTo(LedgerType.TRADE_SELL_BASE_SETTLE); + + assertThat(sellerKrwLedgers).hasSize(1); + assertThat(sellerKrwLedgers.get(0).getType()).isEqualTo(LedgerType.TRADE_SELL_QUOTE_CREDIT); + assertThat(sellerKrwLedgers.get(0).getDeltaAvailable()).isEqualByComparingTo("10000"); + } + + // ── helpers ─────────────────────────────────────────────────────── + + private String signupAndLogin(String email) { + restTemplate.postForEntity( + "/api/v1/auth/signup", + Map.of("email", email, "password", "password1234", "nickname", "tester"), + Map.class + ); + var loginResponse = restTemplate.postForEntity( + "/api/v1/auth/login", + Map.of("email", email, "password", "password1234"), + Map.class + ); + return (String) loginResponse.getBody().get("accessToken"); + } + + private void depositKrw(String email, BigDecimal amount) { + deposit(email, "KRW", amount); + } + + private void depositBtc(String email, BigDecimal amount) { + deposit(email, "BTC", amount); + } + + private void deposit(String email, String asset, BigDecimal amount) { + var user = userRepository.findByEmail(email).orElseThrow(); + Wallet wallet = walletRepository.findAllByUserId(user.getId()).stream() + .filter(w -> w.getAsset().equals(asset)) + .findFirst().orElseThrow(); + wallet.deposit(amount); + walletRepository.save(wallet); + } + + private ResponseEntity createOrder(String token, String market, String side, String type, + String timeInForce, String price, String quantity, + String clientOrderId) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + var body = new java.util.HashMap(); + body.put("market", market); + body.put("side", side); + body.put("type", type); + body.put("timeInForce", timeInForce); + body.put("price", price); + body.put("quantity", quantity); + if (clientOrderId != null) body.put("clientOrderId", clientOrderId); + return restTemplate.exchange("/api/v1/orders", HttpMethod.POST, new HttpEntity<>(body, headers), Map.class); + } + + private ResponseEntity cancelOrder(String token, Long orderId) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + return restTemplate.exchange("/api/v1/orders/" + orderId + "/cancel", HttpMethod.POST, new HttpEntity<>(headers), Map.class); + } + + private ResponseEntity getWallets(String token) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + return restTemplate.exchange("/api/v1/wallets", HttpMethod.GET, new HttpEntity<>(headers), List.class); + } + + private ResponseEntity getLedgers(String token, String asset) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + String url = asset != null ? "/api/v1/wallets/ledgers?asset=" + asset : "/api/v1/wallets/ledgers"; + return restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), List.class); + } +}