diff --git a/.gitignore b/.gitignore index 10458750..06c4fa91 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ out/ **/logs **/db/data -domain/src/main/generated/nexters/payout/domain/stock/domain/QStock.java +**/src/main/generated/** diff --git a/api-server/src/main/java/nexters/payout/apiserver/dividend/application/StockDividendQueryServiceImpl.java b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/StockDividendQueryServiceImpl.java new file mode 100644 index 00000000..baea4cff --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/StockDividendQueryServiceImpl.java @@ -0,0 +1,86 @@ +package nexters.payout.apiserver.dividend.application; + +import lombok.RequiredArgsConstructor; +import nexters.payout.apiserver.stock.application.StockDividendQueryService; +import nexters.payout.apiserver.stock.application.dto.response.DividendResponse; +import nexters.payout.apiserver.stock.application.dto.response.StockDetailResponse; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.dividend.domain.repository.DividendRepository; +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.domain.exception.TickerNotFoundException; +import nexters.payout.domain.stock.domain.repository.StockRepository; +import nexters.payout.domain.stock.domain.service.StockDividendAnalysisService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Month; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StockDividendQueryServiceImpl implements StockDividendQueryService { + + private final StockDividendAnalysisService dividendAnalysisService; + private final StockRepository stockRepository; + private final DividendRepository dividendRepository; + + public StockDetailResponse getStockByTicker(final String ticker) { + Stock stock = getStock(ticker); + + List lastYearDividends = getLastYearDividends(stock); + List thisYearDividends = getThisYearDividends(stock); + + if (lastYearDividends.isEmpty() && thisYearDividends.isEmpty()) { + return StockDetailResponse.of(stock, DividendResponse.noDividend()); + } + + List dividendMonths = dividendAnalysisService.calculateDividendMonths(stock, lastYearDividends); + Double dividendYield = dividendAnalysisService.calculateDividendYield(stock, lastYearDividends); + Double dividendPerShare = dividendAnalysisService.calculateAverageDividend( + combinedDividends(lastYearDividends, thisYearDividends) + ); + + return dividendAnalysisService.findUpcomingDividend(lastYearDividends, thisYearDividends) + .map(upcomingDividend -> StockDetailResponse.of( + stock, + DividendResponse.fullDividendInfo(upcomingDividend, dividendYield, dividendMonths) + )) + .orElse(StockDetailResponse.of( + stock, + DividendResponse.withoutDividendDates(dividendPerShare, dividendYield, dividendMonths) + )); + } + + private List combinedDividends(final List lastYearDividends, final List thisYearDividends) { + return Stream.of(lastYearDividends, thisYearDividends) + .flatMap(List::stream) + .collect(Collectors.toList()); + } + + private Stock getStock(final String ticker) { + return stockRepository.findByTicker(ticker) + .orElseThrow(() -> new TickerNotFoundException(ticker)); + } + + private List getLastYearDividends(final Stock stock) { + int lastYear = InstantProvider.getLastYear(); + + return dividendRepository.findAllByStockId(stock.getId()) + .stream() + .filter(dividend -> InstantProvider.toLocalDate(dividend.getExDividendDate()).getYear() == lastYear) + .collect(Collectors.toList()); + } + + private List getThisYearDividends(final Stock stock) { + int thisYear = InstantProvider.getThisYear(); + + return dividendRepository.findAllByStockId(stock.getId()) + .stream() + .filter(dividend -> InstantProvider.toLocalDate(dividend.getExDividendDate()).getYear() == thisYear) + .collect(Collectors.toList()); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryService.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryService.java new file mode 100644 index 00000000..fe63b1f7 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryService.java @@ -0,0 +1,149 @@ +package nexters.payout.apiserver.portfolio.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nexters.payout.apiserver.portfolio.application.dto.request.PortfolioRequest; +import nexters.payout.apiserver.portfolio.application.dto.response.*; +import nexters.payout.apiserver.stock.application.dto.response.SectorRatioResponse; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.dividend.domain.repository.DividendRepository; +import nexters.payout.domain.portfolio.domain.Portfolio; +import nexters.payout.domain.portfolio.domain.PortfolioStock; +import nexters.payout.domain.portfolio.domain.exception.PortfolioNotFoundException; +import nexters.payout.domain.portfolio.domain.repository.PortfolioRepository; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.domain.exception.StockIdNotFoundException; +import nexters.payout.domain.stock.domain.exception.TickerNotFoundException; +import nexters.payout.domain.stock.domain.repository.StockRepository; +import nexters.payout.domain.stock.domain.service.SectorAnalysisService; +import nexters.payout.domain.stock.domain.service.SectorAnalysisService.SectorInfo; +import nexters.payout.domain.stock.domain.service.SectorAnalysisService.StockShare; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class PortfolioQueryService { + + private final StockRepository stockRepository; + private final PortfolioRepository portfolioRepository; + private final DividendRepository dividendRepository; + private final SectorAnalysisService sectorAnalysisService; + + public PortfolioResponse createPortfolio(final PortfolioRequest request) { + + List portfolioStocks = + request.tickerShares() + .stream() + .map(it -> new PortfolioStock(getStockByTicker(it.ticker()).getId(), it.share())) + .toList(); + + return new PortfolioResponse( + portfolioRepository.save(new Portfolio(InstantProvider.getExpireAt(), portfolioStocks)) + .getId() + ); + } + + @Transactional(readOnly = true) + public List analyzeSectorRatio(final UUID portfolioId) { + List portfolioStocks = getPortfolio(portfolioId).portfolioStocks(); + List stockShares = portfolioStocks + .stream() + .map(ps -> new StockShare(getStock(ps.getStockId()), ps.getShares())) + .toList(); + Map sectorInfoMap = sectorAnalysisService.calculateSectorRatios(stockShares); + return SectorRatioResponse.fromMap(sectorInfoMap); + } + + @Transactional(readOnly = true) + public List getMonthlyDividends(final UUID id) { + return InstantProvider.generateNext12Months() + .stream() + .map(yearMonth -> MonthlyDividendResponse.of( + yearMonth.getYear(), + yearMonth.getMonthValue(), + getDividendsOfLastYearAndMonth( + getPortfolio(id).portfolioStocks(), + yearMonth.getMonthValue() + ) + ) + ) + .collect(Collectors.toList()); + } + + private Stock getStockByTicker(String ticker) { + return stockRepository.findByTicker(ticker) + .orElseThrow(() -> new TickerNotFoundException(ticker)); + } + + private Stock getStock(UUID stockId) { + return stockRepository.findById(stockId).orElseThrow(() -> new StockIdNotFoundException(stockId)); + } + + private Portfolio getPortfolio(UUID id) { + return portfolioRepository.findById(id) + .orElseThrow(() -> new PortfolioNotFoundException(id)); + } + + @Transactional(readOnly = true) + public YearlyDividendResponse getYearlyDividends(final UUID id) { + + List dividends = getPortfolio(id) + .portfolioStocks() + .stream() + .map(portfolioStock -> { + Stock stock = getStock(portfolioStock.getStockId()); + return SingleYearlyDividendResponse.of( + stock, portfolioStock.getShares(), getYearlyDividend(stock.getId()) + ); + }) + .filter(response -> response.totalDividend() != 0) + .toList(); + + return YearlyDividendResponse.of(dividends); + } + + private double getYearlyDividend(final UUID stockId) { + return getLastYearDividendsByStockId(stockId) + .stream() + .mapToDouble(Dividend::getDividend) + .sum(); + } + + private List getLastYearDividendsByStockId(final UUID id) { + return dividendRepository.findAllByIdAndYear(id, InstantProvider.getLastYear()); + } + + private List getDividendsOfLastYearAndMonth( + final List portfolioStocks, final int month + ) { + return portfolioStocks + .stream() + .flatMap(portfolioStock -> stockRepository.findById(portfolioStock.getStockId()) + .map(stock -> getMonthlyDividendResponse(month, portfolioStock, stock)) + .orElseThrow(() -> new StockIdNotFoundException(portfolioStock.getStockId()))) + .toList(); + } + + private Stream getMonthlyDividendResponse( + final int month, final PortfolioStock portfolioStock, final Stock stock + ) { + return getLastYearDividendsByStockIdAndMonth(portfolioStock.getStockId(), month) + .stream() + .map(dividend -> SingleMonthlyDividendResponse.of(stock, portfolioStock.getShares(), dividend)); + } + + private List getLastYearDividendsByStockIdAndMonth(final UUID stockId, final int month) { + return dividendRepository.findAllByIdAndYearAndMonth(stockId, InstantProvider.getLastYear(), month); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/PortfolioRequest.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/PortfolioRequest.java new file mode 100644 index 00000000..612a9bbb --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/PortfolioRequest.java @@ -0,0 +1,15 @@ +package nexters.payout.apiserver.portfolio.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record PortfolioRequest( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Valid + @Size(min = 1) + List tickerShares +) { +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/TickerShare.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/TickerShare.java new file mode 100644 index 00000000..dbc28c16 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/TickerShare.java @@ -0,0 +1,14 @@ +package nexters.payout.apiserver.portfolio.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; + +public record TickerShare( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "ticker name") + @NotEmpty + String ticker, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "share") + @Min(value = 1) + Integer share +) { } \ No newline at end of file diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/MonthlyDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/MonthlyDividendResponse.java new file mode 100644 index 00000000..05ab9d45 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/MonthlyDividendResponse.java @@ -0,0 +1,32 @@ +package nexters.payout.apiserver.portfolio.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Comparator; +import java.util.List; + +public record MonthlyDividendResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Integer year, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Integer month, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List dividends, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double totalDividend +) { + public static MonthlyDividendResponse of( + final int year, final int month, final List dividends + ) { + return new MonthlyDividendResponse( + year, + month, + dividends.stream() + .sorted(Comparator.comparingDouble(SingleMonthlyDividendResponse::totalDividend).reversed()) + .toList(), + dividends.stream() + .mapToDouble(SingleMonthlyDividendResponse::totalDividend) + .sum() + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/PortfolioResponse.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/PortfolioResponse.java new file mode 100644 index 00000000..5a213168 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/PortfolioResponse.java @@ -0,0 +1,11 @@ +package nexters.payout.apiserver.portfolio.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.UUID; + +public record PortfolioResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + UUID id +) { +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SectorRatioResponse.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SectorRatioResponse.java new file mode 100644 index 00000000..5ba5a36d --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SectorRatioResponse.java @@ -0,0 +1,37 @@ +package nexters.payout.apiserver.portfolio.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import nexters.payout.apiserver.stock.application.dto.response.StockShareResponse; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.service.SectorAnalysisService.SectorInfo; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public record SectorRatioResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "sector name") + String sectorName, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "sector value") + String sectorValue, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "sector ratio") + Double sectorRatio, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List stockShares +) { + public static List fromMap(final Map sectorRatioMap) { + return sectorRatioMap.entrySet() + .stream() + .map(entry -> new SectorRatioResponse( + entry.getKey().getName(), + entry.getKey().name(), + entry.getValue().ratio(), + entry.getValue() + .stockShares() + .stream() + .map(StockShareResponse::from) + .collect(Collectors.toList())) + ) + .collect(Collectors.toList()); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleMonthlyDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleMonthlyDividendResponse.java new file mode 100644 index 00000000..1dee85c7 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleMonthlyDividendResponse.java @@ -0,0 +1,28 @@ +package nexters.payout.apiserver.portfolio.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.stock.domain.Stock; + +public record SingleMonthlyDividendResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String ticker, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String logoUrl, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Integer share, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double dividend, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double totalDividend +) { + public static SingleMonthlyDividendResponse of(Stock stock, int share, Dividend dividend) { + return new SingleMonthlyDividendResponse( + stock.getTicker(), + stock.getLogoUrl(), + share, + dividend.getDividend(), + dividend.getDividend() * share + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleYearlyDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleYearlyDividendResponse.java new file mode 100644 index 00000000..6bb7d1b9 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleYearlyDividendResponse.java @@ -0,0 +1,24 @@ +package nexters.payout.apiserver.portfolio.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import nexters.payout.domain.stock.domain.Stock; + +public record SingleYearlyDividendResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String ticker, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String logoUrl, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Integer share, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double totalDividend +) { + public static SingleYearlyDividendResponse of(Stock stock, int share, double dividend) { + return new SingleYearlyDividendResponse( + stock.getTicker(), + stock.getLogoUrl(), + share, + dividend * share + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/YearlyDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/YearlyDividendResponse.java new file mode 100644 index 00000000..d25a10a6 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/YearlyDividendResponse.java @@ -0,0 +1,28 @@ +package nexters.payout.apiserver.portfolio.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Comparator; +import java.util.List; + +public record YearlyDividendResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List dividends, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double totalDividend +) { + public static YearlyDividendResponse of(List dividends) { + + dividends = dividends + .stream() + .sorted(Comparator.comparingDouble(SingleYearlyDividendResponse::totalDividend).reversed()) + .toList(); + return new YearlyDividendResponse( + dividends, + dividends + .stream() + .mapToDouble(SingleYearlyDividendResponse::totalDividend) + .sum() + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/handler/EventFacade.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/handler/EventFacade.java new file mode 100644 index 00000000..b723ceee --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/handler/EventFacade.java @@ -0,0 +1,25 @@ +package nexters.payout.apiserver.portfolio.application.handler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EventFacade { + + private final PortfolioEventHandler portfolioEventHandler; + + @EventListener + void publishReadPortfolioEvent(final ReadPortfolioEvent event) { + try { + portfolioEventHandler.handleReadPortfolioEvent(event); + } catch (ObjectOptimisticLockingFailureException e) { + log.warn("[ReadPortfolioEvent] optimistic lock exception!", e); + publishReadPortfolioEvent(event); + } + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/handler/PortfolioEventHandler.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/handler/PortfolioEventHandler.java new file mode 100644 index 00000000..21f105a9 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/handler/PortfolioEventHandler.java @@ -0,0 +1,22 @@ +package nexters.payout.apiserver.portfolio.application.handler; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import nexters.payout.domain.portfolio.domain.Portfolio; +import nexters.payout.domain.portfolio.domain.exception.PortfolioNotFoundException; +import nexters.payout.domain.portfolio.domain.repository.PortfolioRepository; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Transactional +public class PortfolioEventHandler { + + private final PortfolioRepository portfolioRepository; + + void handleReadPortfolioEvent(final ReadPortfolioEvent event) { + Portfolio portfolio = portfolioRepository.findById(event.portfolioId()) + .orElseThrow(() -> new PortfolioNotFoundException(event.portfolioId())); + portfolio.incrementHits(); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/handler/ReadPortfolioEvent.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/handler/ReadPortfolioEvent.java new file mode 100644 index 00000000..8c6c71b3 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/handler/ReadPortfolioEvent.java @@ -0,0 +1,9 @@ +package nexters.payout.apiserver.portfolio.application.handler; + +import java.util.UUID; + + +public record ReadPortfolioEvent( + UUID portfolioId +) { +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioController.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioController.java new file mode 100644 index 00000000..13acbe39 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioController.java @@ -0,0 +1,52 @@ +package nexters.payout.apiserver.portfolio.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nexters.payout.apiserver.portfolio.application.PortfolioQueryService; +import nexters.payout.apiserver.portfolio.application.dto.request.PortfolioRequest; +import nexters.payout.apiserver.portfolio.application.dto.response.MonthlyDividendResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.PortfolioResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.YearlyDividendResponse; +import nexters.payout.apiserver.stock.application.dto.response.SectorRatioResponse; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import nexters.payout.apiserver.portfolio.application.handler.ReadPortfolioEvent; + + +import java.util.List; +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/portfolios") +@Slf4j +public class PortfolioController implements PortfolioControllerDocs { + + private final PortfolioQueryService portfolioQueryService; + private final ApplicationEventPublisher applicationEventPublisher; + + @PostMapping + public ResponseEntity createPortfolio(@RequestBody @Valid final PortfolioRequest portfolioRequest) { + return ResponseEntity.ok(portfolioQueryService.createPortfolio(portfolioRequest)); + } + + @GetMapping("/{id}/monthly") + public ResponseEntity> getMonthlyDividends(@PathVariable("id") final UUID portfolioId) { + return ResponseEntity.ok(portfolioQueryService.getMonthlyDividends(portfolioId)); + } + + @GetMapping("/{id}/yearly") + public ResponseEntity getYearlyDividends(@PathVariable("id") final UUID portfolioId) { + return ResponseEntity.ok(portfolioQueryService.getYearlyDividends(portfolioId)); + } + + @GetMapping("/{id}/sector-ratio") + public ResponseEntity> getSectorRatios(@PathVariable("id") final UUID portfolioId) { + List result = portfolioQueryService.analyzeSectorRatio(portfolioId); + applicationEventPublisher.publishEvent(new ReadPortfolioEvent(portfolioId)); + log.info(String.format("publish read portfolio event [%s]", portfolioId)); + return ResponseEntity.ok(result); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerDocs.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerDocs.java new file mode 100644 index 00000000..c31ccd70 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerDocs.java @@ -0,0 +1,87 @@ +package nexters.payout.apiserver.portfolio.presentation; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import nexters.payout.apiserver.portfolio.application.dto.request.PortfolioRequest; +import nexters.payout.apiserver.portfolio.application.dto.response.MonthlyDividendResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.PortfolioResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.YearlyDividendResponse; +import nexters.payout.apiserver.stock.application.dto.response.SectorRatioResponse; +import nexters.payout.core.exception.ErrorResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; +import java.util.UUID; + + +public interface PortfolioControllerDocs { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "SUCCESS"), + @ApiResponse(responseCode = "400", description = "BAD REQUEST", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "404", description = "NOT FOUND", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "500", description = "SERVER ERROR", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + }) + @Operation(summary = "포트폴리오 생성", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = PortfolioRequest.class), + examples = { + @ExampleObject(name = "PortfolioRequestExample", value = "{\"tickerShares\":[{\"ticker\":\"AAPL\",\"share\":3}]}") + }))) + ResponseEntity createPortfolio(@RequestBody @Valid final PortfolioRequest portfolioRequest); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "SUCCESS"), + @ApiResponse(responseCode = "400", description = "BAD REQUEST", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "404", description = "NOT FOUND", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "500", description = "SERVER ERROR", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + }) + @Operation(summary = "포트폴리오 기반 월간 배당금 조회") + ResponseEntity> getMonthlyDividends( + @Parameter(description = "portfolio id", example = "bf5ffb6d-ae70-4171-8c86-b27c8ab2efbb", required = true) + @PathVariable("id") final UUID portfolioId + ); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "SUCCESS"), + @ApiResponse(responseCode = "400", description = "BAD REQUEST", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "404", description = "NOT FOUND", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "500", description = "SERVER ERROR", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + }) + @Operation(summary = "포트폴리오 기반 연간 배당금 조회") + ResponseEntity getYearlyDividends( + @Parameter(description = "portfolio id", example = "bf5ffb6d-ae70-4171-8c86-b27c8ab2efbb", required = true) + @PathVariable("id") final UUID portfolioId + ); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "SUCCESS"), + @ApiResponse(responseCode = "400", description = "BAD REQUEST", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "404", description = "NOT FOUND", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "500", description = "SERVER ERROR", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + }) + @Operation(summary = "섹터 비중 분석") + ResponseEntity> getSectorRatios(@PathVariable("id") final UUID portfolioId); +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/application/StockDividendQueryService.java b/api-server/src/main/java/nexters/payout/apiserver/stock/application/StockDividendQueryService.java new file mode 100644 index 00000000..ee0b166e --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/StockDividendQueryService.java @@ -0,0 +1,7 @@ +package nexters.payout.apiserver.stock.application; + +import nexters.payout.apiserver.stock.application.dto.response.StockDetailResponse; + +public interface StockDividendQueryService { + StockDetailResponse getStockByTicker(final String ticker); +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/application/StockQueryService.java b/api-server/src/main/java/nexters/payout/apiserver/stock/application/StockQueryService.java index 7134972d..118da406 100644 --- a/api-server/src/main/java/nexters/payout/apiserver/stock/application/StockQueryService.java +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/StockQueryService.java @@ -5,24 +5,18 @@ import nexters.payout.apiserver.stock.application.dto.request.TickerShare; import nexters.payout.apiserver.stock.application.dto.response.*; import nexters.payout.core.time.InstantProvider; -import nexters.payout.domain.dividend.domain.Dividend; -import nexters.payout.domain.dividend.domain.repository.DividendRepository; import nexters.payout.domain.stock.domain.Sector; import nexters.payout.domain.stock.domain.Stock; -import nexters.payout.domain.stock.domain.exception.TickerNotFoundException; import nexters.payout.domain.stock.domain.repository.StockRepository; import nexters.payout.domain.stock.domain.service.SectorAnalysisService; import nexters.payout.domain.stock.domain.service.SectorAnalysisService.SectorInfo; import nexters.payout.domain.stock.domain.service.SectorAnalysisService.StockShare; -import nexters.payout.domain.stock.domain.service.StockDividendAnalysisService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.Month; import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import java.util.stream.Stream; @Service @RequiredArgsConstructor @@ -30,9 +24,8 @@ public class StockQueryService { private final StockRepository stockRepository; - private final DividendRepository dividendRepository; private final SectorAnalysisService sectorAnalysisService; - private final StockDividendAnalysisService dividendAnalysisService; + private final StockDividendQueryService stockDividendQueryService; public List searchStock(final String keyword, final Integer pageNumber, final Integer pageSize) { return stockRepository.findStocksByTickerOrNameWithPriority(keyword, pageNumber, pageSize) @@ -42,59 +35,7 @@ public List searchStock(final String keyword, final Integer pageN } public StockDetailResponse getStockByTicker(final String ticker) { - Stock stock = getStock(ticker); - - List lastYearDividends = getLastYearDividends(stock); - List thisYearDividends = getThisYearDividends(stock); - - if (lastYearDividends.isEmpty() && thisYearDividends.isEmpty()) { - return StockDetailResponse.of(stock, DividendResponse.noDividend()); - } - - List dividendMonths = dividendAnalysisService.calculateDividendMonths(stock, lastYearDividends); - Double dividendYield = dividendAnalysisService.calculateDividendYield(stock, lastYearDividends); - Double dividendPerShare = dividendAnalysisService.calculateAverageDividend( - combinedDividends(lastYearDividends, thisYearDividends) - ); - - return dividendAnalysisService.findUpcomingDividend(lastYearDividends, thisYearDividends) - .map(upcomingDividend -> StockDetailResponse.of( - stock, - DividendResponse.fullDividendInfo(upcomingDividend, dividendYield, dividendMonths) - )) - .orElse(StockDetailResponse.of( - stock, - DividendResponse.withoutDividendDates(dividendPerShare, dividendYield, dividendMonths) - )); - } - - private List combinedDividends(final List lastYearDividends, final List thisYearDividends) { - return Stream.of(lastYearDividends, thisYearDividends) - .flatMap(List::stream) - .collect(Collectors.toList()); - } - - private Stock getStock(final String ticker) { - return stockRepository.findByTicker(ticker) - .orElseThrow(() -> new TickerNotFoundException(ticker)); - } - - private List getLastYearDividends(final Stock stock) { - int lastYear = InstantProvider.getLastYear(); - - return dividendRepository.findAllByStockId(stock.getId()) - .stream() - .filter(dividend -> InstantProvider.toLocalDate(dividend.getExDividendDate()).getYear() == lastYear) - .collect(Collectors.toList()); - } - - private List getThisYearDividends(final Stock stock) { - int thisYear = InstantProvider.getThisYear(); - - return dividendRepository.findAllByStockId(stock.getId()) - .stream() - .filter(dividend -> InstantProvider.toLocalDate(dividend.getExDividendDate()).getYear() == thisYear) - .collect(Collectors.toList()); + return stockDividendQueryService.getStockByTicker(ticker); } public List analyzeSectorRatio(final SectorRatioRequest request) { diff --git a/api-server/src/test/java/nexters/payout/apiserver/dividend/common/IntegrationTest.java b/api-server/src/test/java/nexters/payout/apiserver/dividend/common/IntegrationTest.java new file mode 100644 index 00000000..f39d3852 --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/dividend/common/IntegrationTest.java @@ -0,0 +1,36 @@ +package nexters.payout.apiserver.dividend.common; + +import io.restassured.RestAssured; +import nexters.payout.domain.dividend.domain.repository.DividendRepository; +import nexters.payout.domain.stock.domain.repository.StockRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +public abstract class IntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + public StockRepository stockRepository; + + @Autowired + public DividendRepository dividendRepository; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @AfterEach + void afterEach() { + dividendRepository.deleteAll(); + stockRepository.deleteAll(); + } +} diff --git a/api-server/src/test/java/nexters/payout/apiserver/dividend/presentation/DividendControllerTest.java b/api-server/src/test/java/nexters/payout/apiserver/dividend/presentation/DividendControllerTest.java index d86b91f6..dd9a50f2 100644 --- a/api-server/src/test/java/nexters/payout/apiserver/dividend/presentation/DividendControllerTest.java +++ b/api-server/src/test/java/nexters/payout/apiserver/dividend/presentation/DividendControllerTest.java @@ -7,7 +7,7 @@ import nexters.payout.apiserver.dividend.application.dto.request.TickerShare; import nexters.payout.apiserver.dividend.application.dto.response.MonthlyDividendResponse; import nexters.payout.apiserver.dividend.application.dto.response.YearlyDividendResponse; -import nexters.payout.apiserver.stock.common.IntegrationTest; +import nexters.payout.apiserver.dividend.common.IntegrationTest; import nexters.payout.core.exception.ErrorResponse; import nexters.payout.core.time.InstantProvider; import nexters.payout.domain.DividendFixture; diff --git a/api-server/src/test/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryServiceTest.java b/api-server/src/test/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryServiceTest.java new file mode 100644 index 00000000..399b080a --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryServiceTest.java @@ -0,0 +1,206 @@ +package nexters.payout.apiserver.portfolio.application; + +import nexters.payout.apiserver.portfolio.application.dto.request.PortfolioRequest; +import nexters.payout.apiserver.portfolio.application.dto.request.TickerShare; +import nexters.payout.apiserver.portfolio.application.dto.response.MonthlyDividendResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.PortfolioResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.YearlyDividendResponse; +import nexters.payout.apiserver.portfolio.application.handler.ReadPortfolioEvent; +import nexters.payout.apiserver.portfolio.common.GivenFixtureTest; +import nexters.payout.apiserver.stock.application.dto.response.SectorRatioResponse; +import nexters.payout.apiserver.stock.application.dto.response.StockResponse; +import nexters.payout.apiserver.stock.application.dto.response.StockShareResponse; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.StockFixture; +import nexters.payout.domain.portfolio.domain.Portfolio; +import nexters.payout.domain.portfolio.domain.PortfolioStock; +import nexters.payout.domain.portfolio.domain.repository.PortfolioRepository; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.domain.service.SectorAnalysisService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static nexters.payout.domain.PortfolioFixture.createPortfolio; +import static nexters.payout.domain.StockFixture.*; +import static nexters.payout.domain.stock.domain.Sector.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PortfolioQueryServiceTest extends GivenFixtureTest { + + @Mock + private PortfolioRepository portfolioRepository; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @InjectMocks + private PortfolioQueryService portfolioQueryService; + + @Spy + private SectorAnalysisService sectorAnalysisService; + + @Test + void 포트폴리오를_생성한다() { + // given + Stock appl = StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 4.0); + Stock tsla = StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL, 2.2); + given(stockRepository.findByTicker(eq(AAPL))).willReturn(Optional.of(appl)); + given(stockRepository.findByTicker(eq(TSLA))).willReturn(Optional.of(tsla)); + + given(portfolioRepository.save(any())).willReturn(createPortfolio( + UUID.fromString("67221662-c2f7-4f35-9447-6a65ca88d5ea"), + InstantProvider.getExpireAt(), + List.of( + new PortfolioStock(UUID.randomUUID(), 2), + new PortfolioStock(UUID.randomUUID(), 1) + ) + ) + ); + String expected = "67221662-c2f7-4f35-9447-6a65ca88d5ea"; + + // when + PortfolioResponse actual = portfolioQueryService.createPortfolio(request()); + + // then + assertThat(actual.id()).isEqualTo(UUID.fromString(expected)); + } + + @Test + void 섹터_정보를_정상적으로_반환한다() { + // given + Stock aapl = StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 2.0); + UUID portfolioId = UUID.randomUUID(); + given(portfolioRepository.findById(portfolioId)).willReturn(Optional.of( + createPortfolio( + List.of(new PortfolioStock(aapl.getId(), 2)) + )) + ); + given(stockRepository.findById(any())).willReturn(Optional.of(aapl)); + + List expected = List.of( + new SectorRatioResponse( + Sector.TECHNOLOGY.getName(), + Sector.TECHNOLOGY.name(), + 1.0, List.of(new StockShareResponse(StockResponse.from(aapl), 2)) + ) + ); + + // when + List actual = portfolioQueryService.analyzeSectorRatio(portfolioId); + + // then + assertThat(actual).containsExactlyInAnyOrderElementsOf(expected); + } + + + @Test + void 사용자의_월간_배당금_정보를_가져온다() { + // given + UUID id = UUID.fromString("bf5ffb6d-ae70-4171-8c86-b27c8ab2efbb"); + givenPortfolioForMonthlyDividend(id); + double expected = 86.8; + + // when + List actual = portfolioQueryService.getMonthlyDividends(id); + + // then + assertAll( + () -> assertThat(actual.size()).isEqualTo(12), + () -> assertThat(actual + .stream() + .mapToDouble(MonthlyDividendResponse::totalDividend) + .sum()).isEqualTo(expected), + () -> assertThat(actual.get(11).dividends().get(0).totalDividend()).isEqualTo(5.0) + ); + } + + @Test + void 사용자의_연간_배당금_정보를_가져온다() { + // given + UUID id = UUID.fromString("bf5ffb6d-ae70-4171-8c86-b27c8ab2efbb"); + givenPortfolioForYearlyDividend(id); + double totalDividendExpected = 86.8; + double aaplDividendExpected = 60.0; + + // when + YearlyDividendResponse actual = portfolioQueryService.getYearlyDividends(id); + + // then + assertAll( + () -> assertThat(actual.totalDividend()).isEqualTo(totalDividendExpected), + () -> assertThat(actual.dividends() + .stream() + .filter(dividend -> dividend.ticker().equals(AAPL)) + .findFirst().get() + .totalDividend()) + .isEqualTo(aaplDividendExpected) + ); + } + + private void givenPortfolioForMonthlyDividend(UUID id) { + Stock aapl = givenStockAndDividendForMonthly(AAPL, TECHNOLOGY, 2.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + Stock tsla = givenStockAndDividendForMonthly(TSLA, UTILITIES, 4.2, 1, 4, 7, 10); + Stock sbux = givenStockAndDividendForMonthly(SBUX, CONSUMER_CYCLICAL, 5.0, 6, 12); + + List portfolioStocks = new ArrayList<>(); + + portfolioStocks.add(new PortfolioStock(aapl.getId(), 2)); + portfolioStocks.add(new PortfolioStock(tsla.getId(), 1)); + portfolioStocks.add(new PortfolioStock(sbux.getId(), 1)); + + Portfolio portfolio = createPortfolio( + id, + LocalDate.now().plusMonths(1).atStartOfDay().toInstant(ZoneOffset.UTC), + portfolioStocks + ); + + given(portfolioRepository.findById(eq(id))).willReturn(Optional.of(portfolio)); + } + + private void givenPortfolioForYearlyDividend(UUID id) { + Stock aapl = givenStockAndDividendForYearly(AAPL, TECHNOLOGY, 2.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + Stock tsla = givenStockAndDividendForYearly(TSLA, UTILITIES, 4.2, 1, 4, 7, 10); + Stock sbux = givenStockAndDividendForYearly(SBUX, CONSUMER_CYCLICAL, 5.0, 6, 12); + + List portfolioStocks = new ArrayList<>(); + + portfolioStocks.add(new PortfolioStock(aapl.getId(), 2)); + portfolioStocks.add(new PortfolioStock(tsla.getId(), 1)); + portfolioStocks.add(new PortfolioStock(sbux.getId(), 1)); + + Portfolio portfolio = createPortfolio( + id, + LocalDate.now().plusMonths(1).atStartOfDay().toInstant(ZoneOffset.UTC), + portfolioStocks + ); + + given(portfolioRepository.findById(eq(id))).willReturn(Optional.of(portfolio)); + } + + private PortfolioRequest request() { + return new PortfolioRequest(List.of( + new TickerShare(AAPL, 2), + new TickerShare(TSLA, 1)) + ); + } +} \ No newline at end of file diff --git a/api-server/src/test/java/nexters/payout/apiserver/portfolio/application/handler/PortfolioEventHandlerTest.java b/api-server/src/test/java/nexters/payout/apiserver/portfolio/application/handler/PortfolioEventHandlerTest.java new file mode 100644 index 00000000..b52bbbb4 --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/portfolio/application/handler/PortfolioEventHandlerTest.java @@ -0,0 +1,128 @@ +package nexters.payout.apiserver.portfolio.application.handler; + +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; +import io.restassured.http.ContentType; +import nexters.payout.apiserver.portfolio.common.IntegrationTest; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.DividendFixture; +import nexters.payout.domain.PortfolioFixture; +import nexters.payout.domain.StockFixture; +import nexters.payout.domain.portfolio.domain.Portfolio; +import nexters.payout.domain.portfolio.domain.PortfolioStock; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import org.junit.jupiter.api.Test; + +import java.time.*; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; + +import static nexters.payout.domain.StockFixture.AAPL; +import static nexters.payout.domain.StockFixture.TSLA; +import static org.apache.http.HttpStatus.SC_OK; +import static org.assertj.core.api.Assertions.assertThat; + +class PortfolioEventHandlerTest extends IntegrationTest { + + @Test + void 포트폴리오_조회시_조회수가_늘어난다() { + // given + Portfolio portfolio = stockAndDividendAndPortfolioGiven(); + portfolioRepository.flush(); + + // when + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get(String.format("api/portfolios/%s/sector-ratio", portfolio.getId())) + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>(){}); + + // then + assertThat(portfolioRepository.findById(portfolio.getId()).get().getHits()).isEqualTo(1); + } + + @Test + void 동시에_포트폴리오를_조회하면_정상적으로_조회수가_늘어난다() throws InterruptedException { + // given + Portfolio portfolio = stockAndDividendAndPortfolioGiven(); + portfolioRepository.flush(); + int threadCount = 100; + CountDownLatch latch = new CountDownLatch(threadCount); + + // when + for (int i = 0; i < threadCount; i++) { + Thread thread = new Thread(new ReadPortfolioTask(portfolio.getId(), latch)); + thread.start(); + } + latch.await(); + + // then + assertThat(portfolioRepository.findById(portfolio.getId()).get().getHits()).isEqualTo(100); + } + + private Portfolio stockAndDividendAndPortfolioGiven() { + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY)); + Stock tsla = stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + + dividendRepository.save(DividendFixture.createDividend( + aapl.getId(), + 2.5, + parseDate(InstantProvider.getLastYear(), 1))); + dividendRepository.save(DividendFixture.createDividend( + aapl.getId(), + 2.5, + parseDate(InstantProvider.getLastYear(), 6))); + dividendRepository.save(DividendFixture.createDividend( + tsla.getId(), + 3.0, + parseDate(InstantProvider.getLastYear(), 6))); + + return portfolioRepository.save(PortfolioFixture.createPortfolio( + LocalDate.now().plusMonths(1).atStartOfDay().toInstant(ZoneOffset.UTC), + List.of(new PortfolioStock(aapl.getId(), 2), new PortfolioStock(tsla.getId(), 1)) + ) + ); + } + + private Instant parseDate(int year, int month) { + LocalDate date = LocalDate.of(year, month, 1); + ZonedDateTime zonedDateTime = date.atStartOfDay(ZoneId.of("UTC")); + return zonedDateTime.toInstant(); + } + + static class ReadPortfolioTask implements Runnable { + + private final UUID id; + private final CountDownLatch latch; + + public ReadPortfolioTask(UUID id, CountDownLatch latch) { + this.id = id; + this.latch = latch; + } + + @Override + public void run() { + try { + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get(String.format("api/portfolios/%s/sector-ratio", id)) + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>(){}); + } catch (Exception e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + } + } +} \ No newline at end of file diff --git a/api-server/src/test/java/nexters/payout/apiserver/portfolio/common/GivenFixtureTest.java b/api-server/src/test/java/nexters/payout/apiserver/portfolio/common/GivenFixtureTest.java new file mode 100644 index 00000000..d55b3496 --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/portfolio/common/GivenFixtureTest.java @@ -0,0 +1,94 @@ +package nexters.payout.apiserver.portfolio.common; + +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.DividendFixture; +import nexters.payout.domain.StockFixture; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.dividend.domain.repository.DividendRepository; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.domain.repository.StockRepository; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.*; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +public abstract class GivenFixtureTest { + + private final Integer JANUARY = 1; + private final Integer DECEMBER = 12; + + @Mock + protected DividendRepository dividendRepository; + + @Mock + protected StockRepository stockRepository; + + public Stock givenStockAndDividendForMonthly(String ticker, Sector sector, double dividend, int... cycle) { + Stock stock = StockFixture.createStock(ticker, sector); + given(stockRepository.findById(eq(stock.getId()))).willReturn(Optional.of(stock)); + + for (int month = JANUARY; month <= DECEMBER; month++) { + if (isContain(cycle, month)) { + // 배당 주기에 해당하는 경우 + given(dividendRepository.findAllByIdAndYearAndMonth( + eq(stock.getId()), + eq(InstantProvider.getLastYear()), + eq(month))) + .willReturn(List.of(DividendFixture.createDividend( + stock.getId(), + dividend, + parseDate(InstantProvider.getLastYear(), month) + ))); + } else { + // 배당 주기에 해당하지 않는 경우 + given(dividendRepository.findAllByIdAndYearAndMonth( + eq(stock.getId()), + eq(InstantProvider.getLastYear()), + eq(month))) + .willReturn(new ArrayList<>()); + } + } + + return stock; + } + + public Stock givenStockAndDividendForYearly(String ticker, Sector sector, double dividend, int... cycle) { + Stock stock = StockFixture.createStock(ticker, sector); + given(stockRepository.findById(eq(stock.getId()))).willReturn(Optional.of(stock)); + + List dividends = new ArrayList<>(); + for (int month : cycle) { + dividends.add(DividendFixture.createDividend( + stock.getId(), + dividend, + parseDate(InstantProvider.getLastYear(), month))); + } + + given(dividendRepository.findAllByIdAndYear( + eq(stock.getId()), + eq(InstantProvider.getLastYear()))) + .willReturn(dividends); + + return stock; + } + + private boolean isContain(int[] cycle, int month) { + return Arrays.stream(cycle).anyMatch(m -> m == month); + } + + private Instant parseDate(int year, int month) { + LocalDate date = LocalDate.of(year, month, 1); + ZonedDateTime zonedDateTime = date.atStartOfDay(ZoneId.of("UTC")); + return zonedDateTime.toInstant(); + } +} diff --git a/api-server/src/test/java/nexters/payout/apiserver/portfolio/common/IntegrationTest.java b/api-server/src/test/java/nexters/payout/apiserver/portfolio/common/IntegrationTest.java new file mode 100644 index 00000000..148c4a5c --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/portfolio/common/IntegrationTest.java @@ -0,0 +1,41 @@ +package nexters.payout.apiserver.portfolio.common; + +import io.restassured.RestAssured; +import nexters.payout.domain.dividend.domain.repository.DividendRepository; +import nexters.payout.domain.portfolio.domain.repository.PortfolioRepository; +import nexters.payout.domain.stock.domain.repository.StockRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +public abstract class IntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + public StockRepository stockRepository; + + @Autowired + public DividendRepository dividendRepository; + + @Autowired + public PortfolioRepository portfolioRepository; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @AfterEach + void afterEach() { + dividendRepository.deleteAll(); + stockRepository.deleteAll(); + portfolioRepository.deleteAll(); + } +} diff --git a/api-server/src/test/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerTest.java b/api-server/src/test/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerTest.java new file mode 100644 index 00000000..3c1738af --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerTest.java @@ -0,0 +1,308 @@ +package nexters.payout.apiserver.portfolio.presentation; + +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; +import io.restassured.http.ContentType; +import nexters.payout.apiserver.portfolio.application.dto.response.MonthlyDividendResponse; +import nexters.payout.apiserver.portfolio.application.dto.request.PortfolioRequest; +import nexters.payout.apiserver.portfolio.application.dto.request.TickerShare; +import nexters.payout.apiserver.portfolio.application.dto.response.SectorRatioResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.YearlyDividendResponse; +import nexters.payout.apiserver.portfolio.common.IntegrationTest; +import nexters.payout.core.exception.ErrorResponse; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.DividendFixture; +import nexters.payout.domain.PortfolioFixture; +import nexters.payout.domain.StockFixture; +import nexters.payout.domain.portfolio.domain.Portfolio; +import nexters.payout.domain.portfolio.domain.PortfolioStock; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import org.assertj.core.data.Offset; +import org.junit.jupiter.api.Test; + +import java.time.*; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import static nexters.payout.domain.StockFixture.AAPL; +import static nexters.payout.domain.StockFixture.TSLA; +import static org.apache.http.HttpStatus.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +public class PortfolioControllerTest extends IntegrationTest { + + @Test + void 포트폴리오_생성시_티커를_찾을수_없는경우_404_예외가_발생한다() { + // given + stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request()) + .when().post("api/portfolios") + .then().log().all() + .statusCode(SC_NOT_FOUND) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 포트폴리오_생성시_빈_리스트로_요청한_경우_400_예외가_발생한다() { + // given + PortfolioRequest request = new PortfolioRequest(new ArrayList<>()); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/portfolios") + .then().log().all() + .statusCode(SC_BAD_REQUEST) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 포트폴리오_생성시_티커가_빈문자열이면_400_예외가_발생한다() { + // given + PortfolioRequest request = new PortfolioRequest(List.of(new TickerShare("", 2))); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/portfolios") + .then().log().all() + .statusCode(SC_BAD_REQUEST) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 포트폴리오_생성시__종목_소유_개수가_0개인_경우_400_예외가_발생한다() { + // given + stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + PortfolioRequest request = new PortfolioRequest(List.of(new TickerShare(TSLA, 0))); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/portfolios") + .then().log().all() + .statusCode(SC_BAD_REQUEST) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 사용자의_섹터_비중을_분석한다() { + // given + Stock tsla = stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL, 10.0)); + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 20.0)); + Portfolio portfolio = portfolioRepository.save(PortfolioFixture.createPortfolio( + List.of(new PortfolioStock(tsla.getId(), 1), new PortfolioStock(aapl.getId(), 1)) + ) + ); + + // when + List actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request()) + .when().get(String.format("api/portfolios/%s/sector-ratio", portfolio.getId())) + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>() { + }); + + List sorted = actual.stream() + .sorted(Comparator.comparing(SectorRatioResponse::sectorRatio)) + .toList(); + // then + assertAll( + () -> assertThat(sorted).hasSize(2), + () -> assertThat(sorted.get(0).sectorRatio()).isCloseTo(0.33, Offset.offset(0.01)), + () -> assertThat(sorted.get(0).sectorName()).isEqualTo(Sector.CONSUMER_CYCLICAL.getName()), + () -> assertThat(sorted.get(1).sectorRatio()).isCloseTo(0.66, Offset.offset(0.01)), + () -> assertThat(sorted.get(1).sectorName()).isEqualTo(Sector.TECHNOLOGY.getName()) + ); + } + + @Test + void 월별_배당금_조회시_배당금이_존재하지_않는_경우_정상적으로_조회된다() { + // given + Stock tsla = stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY)); + Portfolio portfolio = portfolioRepository.save(PortfolioFixture.createPortfolio( + LocalDate.now().plusMonths(1).atStartOfDay().toInstant(ZoneOffset.UTC), + List.of(new PortfolioStock(tsla.getId(), 2), new PortfolioStock(aapl.getId(), 1)) + ) + ); + double expected = 0.0; + + // when + List actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .request() + .body(request()) + .when().get(String.format("api/portfolios/%s/monthly", portfolio.getId())) + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>() { + }); + + assertAll( + () -> assertThat(actual + .stream() + .mapToDouble(MonthlyDividendResponse::totalDividend) + .sum()) + .isEqualTo(expected), + () -> actual.forEach(res -> assertThat(res.dividends()).isEmpty()) + ); + } + + @Test + void 월별_배당금_조회시_배당금이_존재하는_경우_정상적으로_조회된다() { + // given + Portfolio portfolio = stockAndDividendAndPortfolioGiven(); + double expected = 13.0; + + // when + List actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .request() + .body(request()) + .when().get(String.format("api/portfolios/%s/monthly", portfolio.getId())) + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual + .stream() + .mapToDouble(MonthlyDividendResponse::totalDividend) + .sum()) + .isEqualTo(expected), + () -> assertThat(actual).hasSize(12) + ); + } + + @Test + void 연간_배당금_조회시_배당금이_존재하지_않는_경우_정상적으로_조회된다() { + // given + Stock tsla = stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY)); + Portfolio portfolio = portfolioRepository.save(PortfolioFixture.createPortfolio( + LocalDate.now().plusMonths(1).atStartOfDay().toInstant(ZoneOffset.UTC), + List.of(new PortfolioStock(tsla.getId(), 2), new PortfolioStock(aapl.getId(), 1)) + ) + ); + double expected = 0.0; + + // when + YearlyDividendResponse actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .request() + .body(request()) + .when().get(String.format("api/portfolios/%s/yearly", portfolio.getId())) + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>() { + }); + + assertAll( + () -> assertThat(actual.totalDividend()).isEqualTo(expected), + () -> assertThat(actual.dividends()).isEmpty() + ); + } + + @Test + void 연간_배당금_조회시_배당금이_존재하는_경우_정상적으로_조회된다() { + // given + Portfolio portfolio = stockAndDividendAndPortfolioGiven(); + double expected = 13.0; + + // when + YearlyDividendResponse actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .request() + .body(request()) + .when().get(String.format("api/portfolios/%s/yearly", portfolio.getId())) + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual.totalDividend()).isEqualTo(expected), + () -> assertThat(actual.dividends().size()).isEqualTo(2) + ); + } + + private PortfolioRequest request() { + return new PortfolioRequest(List.of( + new TickerShare(AAPL, 2), + new TickerShare(TSLA, 2) + )); + } + + private Portfolio stockAndDividendAndPortfolioGiven() { + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY)); + Stock tsla = stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + + dividendRepository.save(DividendFixture.createDividend( + aapl.getId(), + 2.5, + parseDate(InstantProvider.getLastYear(), 1))); + dividendRepository.save(DividendFixture.createDividend( + aapl.getId(), + 2.5, + parseDate(InstantProvider.getLastYear(), 6))); + dividendRepository.save(DividendFixture.createDividend( + tsla.getId(), + 3.0, + parseDate(InstantProvider.getLastYear(), 6))); + + return portfolioRepository.save(PortfolioFixture.createPortfolio( + LocalDate.now().plusMonths(1).atStartOfDay().toInstant(ZoneOffset.UTC), + List.of(new PortfolioStock(aapl.getId(), 2), new PortfolioStock(tsla.getId(), 1)) + ) + ); + } + + private Instant parseDate(int year, int month) { + LocalDate date = LocalDate.of(year, month, 1); + ZonedDateTime zonedDateTime = date.atStartOfDay(ZoneId.of("UTC")); + return zonedDateTime.toInstant(); + } +} diff --git a/api-server/src/test/java/nexters/payout/apiserver/stock/application/StockQueryServiceTest.java b/api-server/src/test/java/nexters/payout/apiserver/stock/application/StockQueryServiceTest.java index b2fe91ab..bc203e16 100644 --- a/api-server/src/test/java/nexters/payout/apiserver/stock/application/StockQueryServiceTest.java +++ b/api-server/src/test/java/nexters/payout/apiserver/stock/application/StockQueryServiceTest.java @@ -1,5 +1,6 @@ package nexters.payout.apiserver.stock.application; +import nexters.payout.apiserver.dividend.application.StockDividendQueryServiceImpl; import nexters.payout.apiserver.stock.application.dto.request.SectorRatioRequest; import nexters.payout.apiserver.stock.application.dto.request.TickerShare; import nexters.payout.apiserver.stock.application.dto.response.*; @@ -19,9 +20,9 @@ import nexters.payout.domain.stock.domain.service.StockDividendAnalysisService; import nexters.payout.domain.stock.domain.service.SectorAnalysisService; import nexters.payout.domain.stock.infra.dto.StockDividendYieldDto; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @@ -45,9 +46,7 @@ @ExtendWith(MockitoExtension.class) class StockQueryServiceTest { - @InjectMocks private StockQueryService stockQueryService; - @Mock private StockRepository stockRepository; @Mock @@ -57,6 +56,12 @@ class StockQueryServiceTest { @Spy private StockDividendAnalysisService stockDividendAnalysisService; + @BeforeEach + void setUp() { + StockDividendQueryServiceImpl stockDividendQuery = new StockDividendQueryServiceImpl(stockDividendAnalysisService, stockRepository, dividendRepository); + stockQueryService = new StockQueryService(stockRepository, sectorAnalysisService, stockDividendQuery); + } + @Test void 검색된_종목_정보를_정상적으로_반환한다() { // given @@ -106,7 +111,7 @@ class StockQueryServiceTest { int lastYear = LocalDate.now().getYear() - 1; Instant exDividendDate = LocalDate.now().minusYears(1).plusDays(1).atStartOfDay().toInstant(UTC); Stock appl = StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 2.0); - Dividend dividend = DividendFixture.createDividendWithPaymentDate(appl.getId(), 0.5, exDividendDate); + Dividend dividend = DividendFixture.createDividendWithExDividendDate(appl.getId(), 0.5, exDividendDate); given(stockRepository.findByTicker(any())).willReturn(Optional.of(appl)); given(dividendRepository.findAllByStockId(any())).willReturn(List.of(dividend)); diff --git a/api-server/src/test/java/nexters/payout/apiserver/stock/presentation/integration/StockControllerTest.java b/api-server/src/test/java/nexters/payout/apiserver/stock/presentation/integration/StockControllerTest.java index 68846ad3..b3ae28ef 100644 --- a/api-server/src/test/java/nexters/payout/apiserver/stock/presentation/integration/StockControllerTest.java +++ b/api-server/src/test/java/nexters/payout/apiserver/stock/presentation/integration/StockControllerTest.java @@ -193,8 +193,7 @@ class StockControllerTest extends IntegrationTest { Double price = null; Double dividend = 12.0; Stock tsla = stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL, price)); - Instant paymentDate = LocalDate.of(2023, 4, 5).atStartOfDay().toInstant(UTC); - dividendRepository.save(DividendFixture.createDividendWithPaymentDate(tsla.getId(), dividend, paymentDate)); + dividendRepository.save(DividendFixture.createDividendWithDividend(tsla.getId(), dividend)); // when, then StockDetailResponse stockDetailResponse = RestAssured @@ -210,9 +209,7 @@ class StockControllerTest extends IntegrationTest { assertAll( () -> assertThat(stockDetailResponse.dividendPerShare()).isEqualTo(dividend), - () -> assertThat(stockDetailResponse.dividendYield()).isEqualTo(0), - () -> assertThat(stockDetailResponse.earliestPaymentDate()).isEqualTo(LocalDate.of(LocalDate.now().getYear(), 4, 5)), - () -> assertThat(stockDetailResponse.dividendMonths()).isEqualTo(List.of(Month.APRIL)) + () -> assertThat(stockDetailResponse.dividendYield()).isEqualTo(0) ); } diff --git a/batch/src/main/java/nexters/payout/batch/application/DividendBatchService.java b/batch/src/main/java/nexters/payout/batch/application/DividendBatchService.java index a277fe10..11acf82f 100644 --- a/batch/src/main/java/nexters/payout/batch/application/DividendBatchService.java +++ b/batch/src/main/java/nexters/payout/batch/application/DividendBatchService.java @@ -3,7 +3,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import nexters.payout.batch.application.FinancialClient.DividendData; +import nexters.payout.batch.application.client.FinancialClient; +import nexters.payout.batch.application.client.FinancialClient.DividendData; import nexters.payout.domain.dividend.domain.Dividend; import nexters.payout.domain.dividend.application.DividendCommandService; import nexters.payout.domain.stock.domain.Stock; diff --git a/batch/src/main/java/nexters/payout/batch/application/PortfolioBatchService.java b/batch/src/main/java/nexters/payout/batch/application/PortfolioBatchService.java new file mode 100644 index 00000000..002d32b2 --- /dev/null +++ b/batch/src/main/java/nexters/payout/batch/application/PortfolioBatchService.java @@ -0,0 +1,36 @@ +package nexters.payout.batch.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nexters.payout.domain.portfolio.domain.Portfolio; +import nexters.payout.domain.portfolio.domain.repository.PortfolioRepository; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Slf4j +@RequiredArgsConstructor +@Service +@Transactional +public class PortfolioBatchService { + + private final PortfolioRepository portfolioRepository; + + @Scheduled(cron = "${schedules.cron.portfolio}", zone = "UTC") + void deletePortfolio() { + log.info("delete portfolio start.."); + portfolioRepository.deleteAllByIdInQuery(getExpiredPortfolioIds()); + log.info("delete portfolio end.."); + } + + private List getExpiredPortfolioIds() { + return portfolioRepository.findByExpireAtBefore(Instant.now()) + .stream() + .map(Portfolio::getId) + .toList(); + } +} diff --git a/batch/src/main/java/nexters/payout/batch/application/StockBatchService.java b/batch/src/main/java/nexters/payout/batch/application/StockBatchService.java index 8aed70e9..42442b32 100644 --- a/batch/src/main/java/nexters/payout/batch/application/StockBatchService.java +++ b/batch/src/main/java/nexters/payout/batch/application/StockBatchService.java @@ -2,7 +2,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import nexters.payout.batch.application.FinancialClient.StockData; +import nexters.payout.batch.application.client.FinancialClient; +import nexters.payout.batch.application.client.FinancialClient.StockData; +import nexters.payout.batch.application.client.StockLogo; import nexters.payout.domain.stock.application.StockCommandService; import nexters.payout.domain.stock.domain.repository.StockRepository; import org.springframework.scheduling.annotation.Scheduled; @@ -24,7 +26,7 @@ public class StockBatchService { * UTC 시간대 기준 매일 자정에 모든 종목의 현재가와 거래량을 업데이트합니다. */ @Scheduled(cron = "${schedules.cron.stock}", zone = "UTC") - void run() { + void updateStock() { log.info("update stock start.."); List stockList = financialClient.getLatestStockList(); diff --git a/batch/src/main/java/nexters/payout/batch/application/FinancialClient.java b/batch/src/main/java/nexters/payout/batch/application/client/FinancialClient.java similarity index 89% rename from batch/src/main/java/nexters/payout/batch/application/FinancialClient.java rename to batch/src/main/java/nexters/payout/batch/application/client/FinancialClient.java index 5f01f627..e6d2da00 100644 --- a/batch/src/main/java/nexters/payout/batch/application/FinancialClient.java +++ b/batch/src/main/java/nexters/payout/batch/application/client/FinancialClient.java @@ -1,4 +1,4 @@ -package nexters.payout.batch.application; +package nexters.payout.batch.application.client; import nexters.payout.domain.stock.domain.Sector; import nexters.payout.domain.stock.domain.Stock; @@ -24,11 +24,11 @@ record StockData( Integer volume, Integer avgVolume ) { - Stock toDomain() { + public Stock toDomain() { return new Stock(ticker, name, sector, exchange, industry, price, volume, null); } - Stock toDomain(String logoUrl) { + public Stock toDomain(String logoUrl) { return new Stock(ticker, name, sector, exchange, industry, price, volume, logoUrl); } } diff --git a/batch/src/main/java/nexters/payout/batch/application/StockLogo.java b/batch/src/main/java/nexters/payout/batch/application/client/StockLogo.java similarity index 58% rename from batch/src/main/java/nexters/payout/batch/application/StockLogo.java rename to batch/src/main/java/nexters/payout/batch/application/client/StockLogo.java index 46eaa42a..cbc5dd29 100644 --- a/batch/src/main/java/nexters/payout/batch/application/StockLogo.java +++ b/batch/src/main/java/nexters/payout/batch/application/client/StockLogo.java @@ -1,4 +1,4 @@ -package nexters.payout.batch.application; +package nexters.payout.batch.application.client; public interface StockLogo { String getLogoUrl(String ticker); diff --git a/batch/src/main/java/nexters/payout/batch/infra/fmp/FmpDto.java b/batch/src/main/java/nexters/payout/batch/infra/fmp/FmpDto.java index 3ed1fea1..63738123 100644 --- a/batch/src/main/java/nexters/payout/batch/infra/fmp/FmpDto.java +++ b/batch/src/main/java/nexters/payout/batch/infra/fmp/FmpDto.java @@ -3,7 +3,7 @@ import lombok.Getter; import lombok.Setter; -import nexters.payout.batch.application.FinancialClient.DividendData; +import nexters.payout.batch.application.client.FinancialClient.DividendData; import nexters.payout.core.time.DateFormat; @Getter diff --git a/batch/src/main/java/nexters/payout/batch/infra/fmp/FmpFinancialClient.java b/batch/src/main/java/nexters/payout/batch/infra/fmp/FmpFinancialClient.java index 70d11f2f..8e95deb5 100644 --- a/batch/src/main/java/nexters/payout/batch/infra/fmp/FmpFinancialClient.java +++ b/batch/src/main/java/nexters/payout/batch/infra/fmp/FmpFinancialClient.java @@ -1,7 +1,7 @@ package nexters.payout.batch.infra.fmp; import lombok.extern.slf4j.Slf4j; -import nexters.payout.batch.application.FinancialClient; +import nexters.payout.batch.application.client.FinancialClient; import nexters.payout.core.time.DateFormat; import nexters.payout.core.time.InstantProvider; import nexters.payout.domain.stock.domain.Exchange; diff --git a/batch/src/main/java/nexters/payout/batch/infra/ninjas/NinjasFinancialClient.java b/batch/src/main/java/nexters/payout/batch/infra/ninjas/NinjasFinancialClient.java index b4c46b27..2c090869 100644 --- a/batch/src/main/java/nexters/payout/batch/infra/ninjas/NinjasFinancialClient.java +++ b/batch/src/main/java/nexters/payout/batch/infra/ninjas/NinjasFinancialClient.java @@ -1,7 +1,7 @@ package nexters.payout.batch.infra.ninjas; import lombok.extern.slf4j.Slf4j; -import nexters.payout.batch.application.StockLogo; +import nexters.payout.batch.application.client.StockLogo; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; diff --git a/batch/src/main/resources/application-dev.yml b/batch/src/main/resources/application-dev.yml index 980b9ec1..ca92cef9 100644 --- a/batch/src/main/resources/application-dev.yml +++ b/batch/src/main/resources/application-dev.yml @@ -28,6 +28,7 @@ financial: schedules: cron: stock: "0 0 3 * * *" + portfolio: "-" dividend: past: "0 0 4 * * 0" future: "0 0 4 * * *" diff --git a/batch/src/main/resources/application-prod.yml b/batch/src/main/resources/application-prod.yml index 3a459dc4..fed70af8 100644 --- a/batch/src/main/resources/application-prod.yml +++ b/batch/src/main/resources/application-prod.yml @@ -25,6 +25,7 @@ spring: schedules: cron: stock: "0 0 2 * * *" + portfolio: "0 0 0 * * *" dividend: past: "0 0 4 * * 0" future: "0 0 4 * * *" diff --git a/batch/src/main/resources/application-test.yml b/batch/src/main/resources/application-test.yml index 7c3de5e6..7aad06b0 100644 --- a/batch/src/main/resources/application-test.yml +++ b/batch/src/main/resources/application-test.yml @@ -17,6 +17,7 @@ spring: schedules: cron: stock: "-" + portfolio: "-" dividend: past: "-" future: "-" \ No newline at end of file diff --git a/batch/src/test/java/nexters/payout/batch/application/DividendBatchServiceTest.java b/batch/src/test/java/nexters/payout/batch/application/DividendBatchServiceTest.java index 9568a2ca..8fc39687 100644 --- a/batch/src/test/java/nexters/payout/batch/application/DividendBatchServiceTest.java +++ b/batch/src/test/java/nexters/payout/batch/application/DividendBatchServiceTest.java @@ -1,5 +1,6 @@ package nexters.payout.batch.application; +import nexters.payout.batch.application.client.FinancialClient; import nexters.payout.batch.common.AbstractBatchServiceTest; import nexters.payout.domain.DividendFixture; import nexters.payout.domain.StockFixture; diff --git a/batch/src/test/java/nexters/payout/batch/application/LatestStockFixture.java b/batch/src/test/java/nexters/payout/batch/application/LatestStockFixture.java index 9b6e95d8..adddbaac 100644 --- a/batch/src/test/java/nexters/payout/batch/application/LatestStockFixture.java +++ b/batch/src/test/java/nexters/payout/batch/application/LatestStockFixture.java @@ -2,7 +2,7 @@ import nexters.payout.domain.stock.domain.Exchange; import nexters.payout.domain.stock.domain.Sector; -import nexters.payout.batch.application.FinancialClient.StockData; +import nexters.payout.batch.application.client.FinancialClient.StockData; public class LatestStockFixture { public static StockData createStockData(String ticker, Double price, Integer volume) { diff --git a/batch/src/test/java/nexters/payout/batch/application/PortfolioBatchServiceTest.java b/batch/src/test/java/nexters/payout/batch/application/PortfolioBatchServiceTest.java new file mode 100644 index 00000000..8190e790 --- /dev/null +++ b/batch/src/test/java/nexters/payout/batch/application/PortfolioBatchServiceTest.java @@ -0,0 +1,45 @@ +package nexters.payout.batch.application; + +import nexters.payout.batch.common.AbstractBatchServiceTest; +import nexters.payout.domain.PortfolioFixture; +import nexters.payout.domain.portfolio.domain.Portfolio; +import nexters.payout.domain.portfolio.domain.PortfolioStock; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static nexters.payout.domain.PortfolioFixture.STOCK_ID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class PortfolioBatchServiceTest extends AbstractBatchServiceTest { + + @Test + void 만료기간이_지난_포트폴리오는_삭제한다() { + // given + portfolioRepository.save(PortfolioFixture.createPortfolio( + Instant.now().minus(1, ChronoUnit.DAYS), + List.of(new PortfolioStock(STOCK_ID, 1)) + )); + portfolioRepository.save(PortfolioFixture.createPortfolio( + Instant.now().minus(2, ChronoUnit.DAYS), + List.of(new PortfolioStock(STOCK_ID, 2)) + )); + Portfolio notExpiredPortfolio = portfolioRepository.save(PortfolioFixture.createPortfolio( + Instant.now().plus(1, ChronoUnit.DAYS), + List.of(new PortfolioStock(STOCK_ID, 1)) + )); + + // when + portfolioBatchService.deletePortfolio(); + + // then + List actual = portfolioRepository.findAll(); + assertAll( + () -> assertThat(actual).hasSize(1), + () -> assertThat(actual.get(0)).isEqualTo(notExpiredPortfolio) + ); + } +} \ No newline at end of file diff --git a/batch/src/test/java/nexters/payout/batch/application/StockBatchServiceTest.java b/batch/src/test/java/nexters/payout/batch/application/StockBatchServiceTest.java index c452af08..3faab658 100644 --- a/batch/src/test/java/nexters/payout/batch/application/StockBatchServiceTest.java +++ b/batch/src/test/java/nexters/payout/batch/application/StockBatchServiceTest.java @@ -1,5 +1,6 @@ package nexters.payout.batch.application; +import nexters.payout.batch.application.client.FinancialClient; import nexters.payout.batch.common.AbstractBatchServiceTest; import nexters.payout.domain.StockFixture; import nexters.payout.domain.stock.domain.Stock; @@ -23,7 +24,7 @@ class StockBatchServiceTest extends AbstractBatchServiceTest { given(financialClient.getLatestStockList()).willReturn(List.of(stockData)); // when - stockBatchService.run(); + stockBatchService.updateStock(); // then Stock actual = stockRepository.findByTicker(stock.getTicker()).get(); diff --git a/batch/src/test/java/nexters/payout/batch/common/AbstractBatchServiceTest.java b/batch/src/test/java/nexters/payout/batch/common/AbstractBatchServiceTest.java index dde3c178..c1c7d062 100644 --- a/batch/src/test/java/nexters/payout/batch/common/AbstractBatchServiceTest.java +++ b/batch/src/test/java/nexters/payout/batch/common/AbstractBatchServiceTest.java @@ -1,9 +1,11 @@ package nexters.payout.batch.common; import nexters.payout.batch.application.DividendBatchService; -import nexters.payout.batch.application.FinancialClient; +import nexters.payout.batch.application.PortfolioBatchService; +import nexters.payout.batch.application.client.FinancialClient; import nexters.payout.batch.application.StockBatchService; import nexters.payout.domain.dividend.domain.repository.DividendRepository; +import nexters.payout.domain.portfolio.domain.repository.PortfolioRepository; import nexters.payout.domain.stock.domain.repository.StockRepository; import org.junit.jupiter.api.AfterEach; import org.springframework.beans.factory.annotation.Autowired; @@ -23,14 +25,21 @@ public abstract class AbstractBatchServiceTest { @Autowired public DividendRepository dividendRepository; + @Autowired + public PortfolioRepository portfolioRepository; + @Autowired public StockBatchService stockBatchService; @Autowired public DividendBatchService dividendBatchService; + @Autowired + public PortfolioBatchService portfolioBatchService; + @AfterEach void afterEach() { + portfolioRepository.deleteAll(); dividendRepository.deleteAll(); stockRepository.deleteAll(); } diff --git a/core/src/main/java/nexters/payout/core/time/InstantProvider.java b/core/src/main/java/nexters/payout/core/time/InstantProvider.java index cc775f1f..7886f5c2 100644 --- a/core/src/main/java/nexters/payout/core/time/InstantProvider.java +++ b/core/src/main/java/nexters/payout/core/time/InstantProvider.java @@ -42,6 +42,10 @@ public static Instant getYesterday() { return getNow().minusDays(1).atStartOfDay(ZoneId.of("UTC")).toInstant(); } + public static Instant getExpireAt() { + return getNow().plusMonths(1).atStartOfDay(ZoneId.of("UTC")).toInstant(); + } + public static Integer getYear(Instant date) { return ZonedDateTime.ofInstant(date, UTC).getYear(); } diff --git a/domain/src/main/generated/nexters/payout/domain/QBaseEntity.java b/domain/src/main/generated/nexters/payout/domain/QBaseEntity.java deleted file mode 100644 index 2abee65d..00000000 --- a/domain/src/main/generated/nexters/payout/domain/QBaseEntity.java +++ /dev/null @@ -1,41 +0,0 @@ -package nexters.payout.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QBaseEntity is a Querydsl query type for BaseEntity - */ -@Generated("com.querydsl.codegen.DefaultSupertypeSerializer") -public class QBaseEntity extends EntityPathBase { - - private static final long serialVersionUID = -300935343L; - - public static final QBaseEntity baseEntity = new QBaseEntity("baseEntity"); - - public final DateTimePath createdAt = createDateTime("createdAt", java.time.Instant.class); - - public final ComparablePath id = createComparable("id", java.util.UUID.class); - - public final DateTimePath lastModifiedAt = createDateTime("lastModifiedAt", java.time.Instant.class); - - public QBaseEntity(String variable) { - super(BaseEntity.class, forVariable(variable)); - } - - public QBaseEntity(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QBaseEntity(PathMetadata metadata) { - super(BaseEntity.class, metadata); - } - -} - diff --git a/domain/src/main/generated/nexters/payout/domain/dividend/domain/QDividend.java b/domain/src/main/generated/nexters/payout/domain/dividend/domain/QDividend.java deleted file mode 100644 index 02e8370d..00000000 --- a/domain/src/main/generated/nexters/payout/domain/dividend/domain/QDividend.java +++ /dev/null @@ -1,56 +0,0 @@ -package nexters.payout.domain.dividend.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QDividend is a Querydsl query type for Dividend - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QDividend extends EntityPathBase { - - private static final long serialVersionUID = -1959252905L; - - public static final QDividend dividend1 = new QDividend("dividend1"); - - public final nexters.payout.domain.QBaseEntity _super = new nexters.payout.domain.QBaseEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final DateTimePath declarationDate = createDateTime("declarationDate", java.time.Instant.class); - - public final NumberPath dividend = createNumber("dividend", Double.class); - - public final DateTimePath exDividendDate = createDateTime("exDividendDate", java.time.Instant.class); - - //inherited - public final ComparablePath id = _super.id; - - //inherited - public final DateTimePath lastModifiedAt = _super.lastModifiedAt; - - public final DateTimePath paymentDate = createDateTime("paymentDate", java.time.Instant.class); - - public final ComparablePath stockId = createComparable("stockId", java.util.UUID.class); - - public QDividend(String variable) { - super(Dividend.class, forVariable(variable)); - } - - public QDividend(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QDividend(PathMetadata metadata) { - super(Dividend.class, metadata); - } - -} - diff --git a/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryCustom.java b/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryCustom.java index dbd020d6..fd1e12c9 100644 --- a/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryCustom.java +++ b/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryCustom.java @@ -12,6 +12,8 @@ public interface DividendRepositoryCustom { Optional findByStockIdAndExDividendDate(UUID stockId, Instant date); List findAllByTickerAndYearAndMonth(String ticker, Integer year, Integer month); + List findAllByIdAndYearAndMonth(UUID id, Integer year, Integer month); List findAllByTickerAndYear(String ticker, Integer year); + List findAllByIdAndYear(UUID id, Integer year); void deleteByYearAndCreatedAt(Integer year, Instant createdAt); } diff --git a/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryImpl.java b/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryImpl.java index cbf7b9e7..b0c07574 100644 --- a/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryImpl.java +++ b/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryImpl.java @@ -51,6 +51,18 @@ public List findAllByTickerAndYearAndMonth(String ticker, Integer year .fetch(); } + @Override + public List findAllByIdAndYearAndMonth(UUID id, Integer year, Integer month) { + + return queryFactory + .selectFrom(dividend1) + .innerJoin(stock).on(dividend1.stockId.eq(stock.id)) + .where(dividend1.exDividendDate.year().eq(year) + .and(dividend1.exDividendDate.month().eq(month)) + .and(stock.id.eq(id))) + .fetch(); + } + @Override public List findAllByTickerAndYear(String ticker, Integer year) { @@ -62,6 +74,17 @@ public List findAllByTickerAndYear(String ticker, Integer year) { .fetch(); } + @Override + public List findAllByIdAndYear(UUID id, Integer year) { + + return queryFactory + .selectFrom(dividend1) + .innerJoin(stock).on(dividend1.stockId.eq(stock.id)) + .where(dividend1.exDividendDate.year().eq(year) + .and(stock.id.eq(id))) + .fetch(); + } + @Override public void deleteByYearAndCreatedAt(Integer year, Instant createdAt) { @@ -73,4 +96,6 @@ public void deleteByYearAndCreatedAt(Integer year, Instant createdAt) { .and(dividend1.createdAt.dayOfMonth().eq(InstantProvider.getDayOfMonth(createdAt)))) .execute(); } + + } diff --git a/domain/src/main/java/nexters/payout/domain/portfolio/domain/Portfolio.java b/domain/src/main/java/nexters/payout/domain/portfolio/domain/Portfolio.java new file mode 100644 index 00000000..01065872 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/portfolio/domain/Portfolio.java @@ -0,0 +1,56 @@ +package nexters.payout.domain.portfolio.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import nexters.payout.domain.BaseEntity; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + + +@Entity +@Getter +public class Portfolio extends BaseEntity { + + @Embedded + private PortfolioStocks portfolioStocks; + + private Instant expireAt; + + private Integer hits; + + @Version + private Long version = 0L; + + public Portfolio() { + super(null); + } + + public Portfolio(final UUID id, final Instant expireAt, List stocks) { + super(id); + this.portfolioStocks = new PortfolioStocks(stocks); + this.expireAt = expireAt; + this.hits = 0; + } + + public Portfolio(final Instant expireAt, List stocks) { + super(null); + this.portfolioStocks = new PortfolioStocks(stocks); + this.expireAt = expireAt; + this.hits = 0; + } + + public List portfolioStocks() { + return Collections.unmodifiableList(portfolioStocks.stockShares()); + } + + public void incrementHits() { + hits++; + } + + public boolean isExpired() { + return expireAt.isAfter(Instant.now()); + } +} diff --git a/domain/src/main/java/nexters/payout/domain/portfolio/domain/PortfolioStock.java b/domain/src/main/java/nexters/payout/domain/portfolio/domain/PortfolioStock.java new file mode 100644 index 00000000..a410116f --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/portfolio/domain/PortfolioStock.java @@ -0,0 +1,22 @@ +package nexters.payout.domain.portfolio.domain; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PortfolioStock { + + private UUID stockId; + private Integer shares; + + public PortfolioStock(final UUID stockId, final Integer shares) { + this.stockId = stockId; + this.shares = shares; + } +} diff --git a/domain/src/main/java/nexters/payout/domain/portfolio/domain/PortfolioStocks.java b/domain/src/main/java/nexters/payout/domain/portfolio/domain/PortfolioStocks.java new file mode 100644 index 00000000..bfe3e0bf --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/portfolio/domain/PortfolioStocks.java @@ -0,0 +1,31 @@ +package nexters.payout.domain.portfolio.domain; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embeddable; +import jakarta.persistence.JoinColumn; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@NoArgsConstructor +@Embeddable +public class PortfolioStocks { + + @ElementCollection + @CollectionTable(name = "portfolio_stock", joinColumns = @JoinColumn(name = "portfolio_id")) + private List portfolioStocks = new ArrayList<>(); + + public PortfolioStocks(List stocks) { + if (stocks.isEmpty()) { + throw new IllegalArgumentException("portfolioStocks must not be empty"); + } + portfolioStocks = stocks; + } + + public List stockShares() { + return Collections.unmodifiableList(portfolioStocks); + } +} diff --git a/domain/src/main/java/nexters/payout/domain/portfolio/domain/exception/PortfolioNotFoundException.java b/domain/src/main/java/nexters/payout/domain/portfolio/domain/exception/PortfolioNotFoundException.java new file mode 100644 index 00000000..51442250 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/portfolio/domain/exception/PortfolioNotFoundException.java @@ -0,0 +1,12 @@ +package nexters.payout.domain.portfolio.domain.exception; + +import nexters.payout.core.exception.error.NotFoundException; + +import java.util.UUID; + +public class PortfolioNotFoundException extends NotFoundException { + + public PortfolioNotFoundException(UUID id) { + super(String.format("not found portfolio [%s]", id)); + } +} diff --git a/domain/src/main/java/nexters/payout/domain/portfolio/domain/repository/PortfolioRepository.java b/domain/src/main/java/nexters/payout/domain/portfolio/domain/repository/PortfolioRepository.java new file mode 100644 index 00000000..edadb569 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/portfolio/domain/repository/PortfolioRepository.java @@ -0,0 +1,18 @@ +package nexters.payout.domain.portfolio.domain.repository; + +import nexters.payout.domain.portfolio.domain.Portfolio; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public interface PortfolioRepository extends JpaRepository { + List findByExpireAtBefore(Instant date); + + @Modifying(clearAutomatically = true) + @Query("delete from Portfolio p where p.id in :ids") + void deleteAllByIdInQuery(List ids); +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/domain/exception/StockIdNotFoundException.java b/domain/src/main/java/nexters/payout/domain/stock/domain/exception/StockIdNotFoundException.java new file mode 100644 index 00000000..54ed6dad --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/domain/exception/StockIdNotFoundException.java @@ -0,0 +1,12 @@ +package nexters.payout.domain.stock.domain.exception; + +import nexters.payout.core.exception.error.NotFoundException; + +import java.util.UUID; + +public class StockIdNotFoundException extends NotFoundException { + + public StockIdNotFoundException(UUID id) { + super(String.format("not found stock id [%s]", id)); + } +} diff --git a/domain/src/main/resources/db/migration/V5__add_portfolio.sql b/domain/src/main/resources/db/migration/V5__add_portfolio.sql new file mode 100644 index 00000000..40469046 --- /dev/null +++ b/domain/src/main/resources/db/migration/V5__add_portfolio.sql @@ -0,0 +1,16 @@ +create table portfolio +( + id binary (16) not null + primary key, + expire_at datetime(6), + created_at datetime(6), + last_modified_at datetime(6) +) engine = innodb + default charset = utf8mb4; + +create table portfolio_stock ( + portfolio_id binary(16) not null, + stock_id binary(16), + shares integer +) engine=InnoDB + default charset = utf8mb4; \ No newline at end of file diff --git a/domain/src/main/resources/db/migration/V6__add_portfolio_hits.sql b/domain/src/main/resources/db/migration/V6__add_portfolio_hits.sql new file mode 100644 index 00000000..dc037e21 --- /dev/null +++ b/domain/src/main/resources/db/migration/V6__add_portfolio_hits.sql @@ -0,0 +1,5 @@ +alter table portfolio + add hits int not null; + +alter table portfolio + add version bigint not null default 1; \ No newline at end of file diff --git a/domain/src/test/java/nexters/payout/domain/stock/service/StockDividendAnalysisServiceTest.java b/domain/src/test/java/nexters/payout/domain/stock/service/StockDividendAnalysisServiceTest.java index d31b555f..99fc987f 100644 --- a/domain/src/test/java/nexters/payout/domain/stock/service/StockDividendAnalysisServiceTest.java +++ b/domain/src/test/java/nexters/payout/domain/stock/service/StockDividendAnalysisServiceTest.java @@ -1,5 +1,6 @@ package nexters.payout.domain.stock.service; +import nexters.payout.core.time.InstantProvider; import nexters.payout.domain.DividendFixture; import nexters.payout.domain.StockFixture; import nexters.payout.domain.dividend.domain.Dividend; @@ -76,18 +77,17 @@ class StockDividendAnalysisServiceTest { @Test void 공시된_현재_배당금_지급일이_없는_경우_과거데이터를_기반으로_가까운_지급일을_계산한다() { // given - LocalDate now = LocalDate.now(); + LocalDate now = LocalDate.ofInstant(Instant.now(), UTC); Dividend pastDividend = DividendFixture.createDividendWithExDividendDate( UUID.randomUUID(), - LocalDate.of(now.getYear() - 1, 1, 10) + LocalDate.of(now.getYear(), now.getMonth().minus(1), 1) .atStartOfDay(ZoneId.systemDefault()).toInstant() ); Dividend earlistDividend = DividendFixture.createDividendWithExDividendDate( UUID.randomUUID(), - LocalDate.of(now.getYear() - 1, 3, 10) - .atStartOfDay(ZoneId.systemDefault()).toInstant() + LocalDate.of(now.getYear(), now.getMonth(), now.getDayOfMonth()).atStartOfDay().toInstant(UTC) ); List lastYearDividends = List.of(pastDividend, earlistDividend); @@ -107,7 +107,7 @@ class StockDividendAnalysisServiceTest { Dividend lastYearDividend = DividendFixture.createDividend( UUID.randomUUID(), 1.0, - LocalDate.now().plusDays(10) + LocalDate.now().minusDays(10) .atStartOfDay(ZoneId.systemDefault()).toInstant() ); diff --git a/domain/src/testFixtures/java/nexters/payout/domain/DividendFixture.java b/domain/src/testFixtures/java/nexters/payout/domain/DividendFixture.java index 606afe99..acc1a5f4 100644 --- a/domain/src/testFixtures/java/nexters/payout/domain/DividendFixture.java +++ b/domain/src/testFixtures/java/nexters/payout/domain/DividendFixture.java @@ -28,14 +28,14 @@ public static Dividend createDividendWithExDividendDate(UUID stockId, Instant ex Instant.parse("2023-12-22T00:00:00Z")); } - public static Dividend createDividendWithPaymentDate(UUID stockId, Double dividend, Instant paymentDate) { + public static Dividend createDividendWithExDividendDate(UUID stockId, Double dividend, Instant exDividendDate) { return new Dividend( UUID.randomUUID(), stockId, dividend, - paymentDate, - paymentDate, - paymentDate); + exDividendDate, + exDividendDate, + exDividendDate); } public static Dividend createDividend(UUID stockId, Double dividend, Instant exDividendDate) { diff --git a/domain/src/testFixtures/java/nexters/payout/domain/PortfolioFixture.java b/domain/src/testFixtures/java/nexters/payout/domain/PortfolioFixture.java new file mode 100644 index 00000000..542b8573 --- /dev/null +++ b/domain/src/testFixtures/java/nexters/payout/domain/PortfolioFixture.java @@ -0,0 +1,26 @@ +package nexters.payout.domain; + +import nexters.payout.domain.portfolio.domain.Portfolio; +import nexters.payout.domain.portfolio.domain.PortfolioStock; +import nexters.payout.domain.portfolio.domain.PortfolioStocks; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public class PortfolioFixture { + + public static UUID STOCK_ID = UUID.randomUUID(); + + public static Portfolio createPortfolio(UUID id, Instant expireAt, List stocks) { + return new Portfolio(id, expireAt, stocks); + } + + public static Portfolio createPortfolio(Instant expireAt, List stocks) { + return new Portfolio(UUID.randomUUID(), expireAt, stocks); + } + + public static Portfolio createPortfolio(List stocks) { + return new Portfolio(UUID.randomUUID(), Instant.now(), stocks); + } +}