diff --git a/src/main/java/com/coinflow/order/service/OrderService.java b/src/main/java/com/coinflow/order/service/OrderService.java index fe5cc94..439389f 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 @@ -114,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); @@ -133,6 +143,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 +204,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); } 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/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/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/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); +} 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(); + } +} 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); + } +}