diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 4e04e179..16b7d8ab 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -26,4 +26,7 @@ jobs: run: chmod +x gradlew - name: Build with Gradle - run: ./gradlew build \ No newline at end of file + run: ./gradlew build + env: + FMP_API_KEY: ${{ secrets.FMP_API_KEY }} + NINJAS_API_KEY: ${{ secrets.NINJAS_API_KEY }} \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..eb91add0 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,80 @@ +name: Backend CD + +on: + push: + branches: + - main + - develop + - fix/#77 + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Execute Gradle build + run: ./gradlew clean build + + - name: Set up Docker Build + uses: docker/setup-buildx-action@v2 + + - name: Docker build and push to NCP container registry and copy docker-compose.yml to server + run: | + cd ./api-server + sudo docker build --build-arg DEPENDENCY=build/dependency -t ${{ secrets.NCP_CONTAINER_REGISTRY_API }}/payout-api --platform linux/amd64 . + sudo docker login ${{ secrets.NCP_CONTAINER_REGISTRY_API }} -u ${{ secrets.NCP_ACCESS_KEY }} -p ${{ secrets.NCP_SECRET_KEY }} + sudo docker push ${{ secrets.NCP_CONTAINER_REGISTRY_API }}/payout-api + + cd ../batch + sudo docker build --build-arg DEPENDENCY=build/dependency -t ${{ secrets.NCP_CONTAINER_REGISTRY_BATCH }}/payout-batch --platform linux/amd64 . + sudo docker login ${{ secrets.NCP_CONTAINER_REGISTRY_BATCH }} -u ${{ secrets.NCP_ACCESS_KEY }} -p ${{ secrets.NCP_SECRET_KEY }} + sudo docker push ${{ secrets.NCP_CONTAINER_REGISTRY_BATCH }}/payout-batch + + cd .. + sshpass -p ${{ secrets.API_SERVER_PASSWORD }} scp -o StrictHostKeyChecking=no ./docker-compose.yml ${{ secrets.API_SERVER_USERNAME }}@${{ secrets.API_SERVER_HOST }}:${{ secrets.DOCKER_COMPOSE_PATH }} + sshpass -p ${{ secrets.API_SERVER_PASSWORD }} scp -o StrictHostKeyChecking=no ./nginx/nginx.conf ${{ secrets.API_SERVER_USERNAME }}@${{ secrets.API_SERVER_HOST }}:${{ secrets.DOCKER_COMPOSE_PATH }} + sshpass -p ${{ secrets.API_SERVER_PASSWORD }} scp -o StrictHostKeyChecking=no ./deploy.sh ${{ secrets.API_SERVER_USERNAME }}@${{ secrets.API_SERVER_HOST }}:${{ secrets.DOCKER_COMPOSE_PATH }} + shell: bash + + deploy-to-server: + name: Connect api server ssh and pull from container registry + needs: build-and-push + runs-on: ubuntu-latest + steps: + ## docker compose up + - name: Deploy to api server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.API_SERVER_HOST }} + username: ${{ secrets.API_SERVER_USERNAME }} + password: ${{ secrets.API_SERVER_PASSWORD }} + script: | + export DB_HOSTNAME=${{ secrets.DB_HOSTNAME }} + export DB_PORT=${{ secrets.DB_PORT }} + export DB_DATABASE=${{ secrets.DB_DATABASE }} + export DB_USERNAME=${{ secrets.DB_USERNAME }} + export DB_PASSWORD=${{ secrets.DB_PASSWORD }} + export FMP_API_KEY=${{ secrets.FMP_API_KEY }} + export NINJAS_API_KEY=${{ secrets.NINJAS_API_KEY }} + export NCP_CONTAINER_REGISTRY_API=${{ secrets.NCP_CONTAINER_REGISTRY_API }} + export NCP_CONTAINER_REGISTRY_BATCH=${{ secrets.NCP_CONTAINER_REGISTRY_BATCH }} + + sudo docker login ${{ secrets.NCP_CONTAINER_REGISTRY_API }} -u ${{ secrets.NCP_ACCESS_KEY }} -p ${{ secrets.NCP_SECRET_KEY }} + sudo docker pull ${{ secrets.NCP_CONTAINER_REGISTRY_API }}/payout-api + sudo docker login ${{ secrets.NCP_CONTAINER_REGISTRY_BATCH }} -u ${{ secrets.NCP_ACCESS_KEY }} -p ${{ secrets.NCP_SECRET_KEY }} + sudo docker pull ${{ secrets.NCP_CONTAINER_REGISTRY_BATCH }}/payout-batch + + bash ${{ secrets.DOCKER_COMPOSE_PATH }}/deploy.sh + + docker image prune -f \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2eb1fafa..10458750 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,6 @@ out/ .vscode/ .DS_Store -**/logs \ No newline at end of file +**/logs +**/db/data +domain/src/main/generated/nexters/payout/domain/stock/domain/QStock.java diff --git a/api-server/Dockerfile b/api-server/Dockerfile new file mode 100644 index 00000000..6e14fe64 --- /dev/null +++ b/api-server/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:17 + +ARG JAR_FILE=build/libs/api-server.jar +COPY ${JAR_FILE} api-server.jar +ENTRYPOINT ["java","-jar","-Dspring.profiles.active=prod,file-logging","-Duser.timezone=UTC","/api-server.jar"] \ No newline at end of file diff --git a/api-server/build.gradle b/api-server/build.gradle index 632f4fcb..a640cb42 100644 --- a/api-server/build.gradle +++ b/api-server/build.gradle @@ -6,9 +6,20 @@ plugins { dependencies { implementation(project(":core")) + implementation(project(":domain")) + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.rest-assured:rest-assured:5.3.0' + + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + + testImplementation(testFixtures(project(":domain"))) } tasks.named('test') { diff --git a/api-server/src/main/java/nexters/dividend/apiserver/DividendApiServerApplication.java b/api-server/src/main/java/nexters/dividend/apiserver/DividendApiServerApplication.java deleted file mode 100644 index d66a9812..00000000 --- a/api-server/src/main/java/nexters/dividend/apiserver/DividendApiServerApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package nexters.dividend.apiserver; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class DividendApiServerApplication { - - public static void main(String[] args) { - SpringApplication.run(DividendApiServerApplication.class, args); - } - -} diff --git a/api-server/src/main/java/nexters/payout/apiserver/PayoutApiServerApplication.java b/api-server/src/main/java/nexters/payout/apiserver/PayoutApiServerApplication.java new file mode 100644 index 00000000..bc042a32 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/PayoutApiServerApplication.java @@ -0,0 +1,19 @@ +package nexters.payout.apiserver; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@ConfigurationPropertiesScan +@SpringBootApplication(scanBasePackages = { + "nexters.payout.core", + "nexters.payout.domain", + "nexters.payout.apiserver" +}) +public class PayoutApiServerApplication { + + public static void main(String[] args) { + SpringApplication.run(PayoutApiServerApplication.class, args); + } + +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/config/SwaggerConfig.java b/api-server/src/main/java/nexters/payout/apiserver/config/SwaggerConfig.java new file mode 100644 index 00000000..40f8b500 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/config/SwaggerConfig.java @@ -0,0 +1,26 @@ +package nexters.payout.apiserver.config; + +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .components(new Components()) + .addServersItem(new Server().url("/")) + .info(getPayoutServerInfo()); + } + + private Info getPayoutServerInfo() { + return new Info().title("Payout Server API") + .description("Payout Server API 명세서입니다.") + .version("1.0.0"); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/config/WebConfig.java b/api-server/src/main/java/nexters/payout/apiserver/config/WebConfig.java new file mode 100644 index 00000000..be3d3ded --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/config/WebConfig.java @@ -0,0 +1,19 @@ +package nexters.payout.apiserver.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("http://localhost:3000", "http://localhost:8080") + .allowedOriginPatterns("*") + .allowedMethods("*") + .allowedHeaders("*") + .allowCredentials(true); + } +} \ No newline at end of file diff --git a/api-server/src/main/java/nexters/payout/apiserver/dividend/application/DividendQueryService.java b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/DividendQueryService.java new file mode 100644 index 00000000..a9f6db6f --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/DividendQueryService.java @@ -0,0 +1,98 @@ +package nexters.payout.apiserver.dividend.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nexters.payout.apiserver.dividend.application.dto.request.DividendRequest; +import nexters.payout.apiserver.dividend.application.dto.request.TickerShare; +import nexters.payout.apiserver.dividend.application.dto.response.SingleMonthlyDividendResponse; +import nexters.payout.apiserver.dividend.application.dto.response.MonthlyDividendResponse; +import nexters.payout.apiserver.dividend.application.dto.response.SingleYearlyDividendResponse; +import nexters.payout.apiserver.dividend.application.dto.response.YearlyDividendResponse; +import nexters.payout.core.exception.error.NotFoundException; +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.repository.StockRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class DividendQueryService { + + private final DividendRepository dividendRepository; + private final StockRepository stockRepository; + + public List getMonthlyDividends(final DividendRequest request) { + return InstantProvider.generateNext12Months() + .stream() + .map(yearMonth -> MonthlyDividendResponse.of( + yearMonth.getYear(), + yearMonth.getMonthValue(), + getDividendsOfLastYearAndMonth(request.tickerShares(), yearMonth.getMonthValue()) + ) + ) + .collect(Collectors.toList()); + } + + public YearlyDividendResponse getYearlyDividends(final DividendRequest request) { + List dividends = request.tickerShares() + .stream() + .map(tickerShare -> { + String ticker = tickerShare.ticker(); + return SingleYearlyDividendResponse.of( + getStock(ticker), tickerShare.share(), getYearlyDividend(ticker) + ); + }) + .filter(response -> response.totalDividend() != 0) + .collect(Collectors.toList()); + + return YearlyDividendResponse.of(dividends); + } + + private double getYearlyDividend(final String ticker) { + return getLastYearDividendsByTicker(ticker) + .stream() + .mapToDouble(Dividend::getDividend) + .sum(); + } + + private List getLastYearDividendsByTicker(final String ticker) { + return dividendRepository.findAllByTickerAndYear(ticker, InstantProvider.getLastYear()); + } + + private Stock getStock(final String ticker) { + return stockRepository.findByTicker(ticker) + .orElseThrow(() -> new NotFoundException(String.format("not found ticker [%s]", ticker))); + } + + private List getDividendsOfLastYearAndMonth( + final List tickerShares, final int month + ) { + return tickerShares + .stream() + .flatMap(tickerShare -> stockRepository.findByTicker(tickerShare.ticker()) + .map(stock -> getMonthlyDividendResponse(month, tickerShare, stock)) + .orElseThrow(() -> new NotFoundException(String.format("not found ticker [%s]", tickerShare.ticker())))) + .toList(); + } + + private Stream getMonthlyDividendResponse( + final int month, final TickerShare tickerShare, final Stock stock + ) { + return getLastYearDividendsByTickerAndMonth(tickerShare.ticker(), month) + .stream() + .map(dividend -> SingleMonthlyDividendResponse.of(stock, tickerShare.share(), dividend)); + } + + private List getLastYearDividendsByTickerAndMonth(final String ticker, final int month) { + return dividendRepository.findAllByTickerAndYearAndMonth(ticker, InstantProvider.getLastYear(), month); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/request/DividendRequest.java b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/request/DividendRequest.java new file mode 100644 index 00000000..8d616bd6 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/request/DividendRequest.java @@ -0,0 +1,15 @@ +package nexters.payout.apiserver.dividend.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 DividendRequest( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Valid + @Size(min = 1) + List tickerShares +) { +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/request/TickerShare.java b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/request/TickerShare.java new file mode 100644 index 00000000..5011497a --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/request/TickerShare.java @@ -0,0 +1,14 @@ +package nexters.payout.apiserver.dividend.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/dividend/application/dto/response/MonthlyDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/response/MonthlyDividendResponse.java new file mode 100644 index 00000000..74868181 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/response/MonthlyDividendResponse.java @@ -0,0 +1,32 @@ +package nexters.payout.apiserver.dividend.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/dividend/application/dto/response/SingleMonthlyDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/response/SingleMonthlyDividendResponse.java new file mode 100644 index 00000000..76be8ba0 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/response/SingleMonthlyDividendResponse.java @@ -0,0 +1,28 @@ +package nexters.payout.apiserver.dividend.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/dividend/application/dto/response/SingleYearlyDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/response/SingleYearlyDividendResponse.java new file mode 100644 index 00000000..966ee592 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/response/SingleYearlyDividendResponse.java @@ -0,0 +1,24 @@ +package nexters.payout.apiserver.dividend.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/dividend/application/dto/response/YearlyDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/response/YearlyDividendResponse.java new file mode 100644 index 00000000..0fe15be8 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/dividend/application/dto/response/YearlyDividendResponse.java @@ -0,0 +1,28 @@ +package nexters.payout.apiserver.dividend.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/dividend/presentation/DividendController.java b/api-server/src/main/java/nexters/payout/apiserver/dividend/presentation/DividendController.java new file mode 100644 index 00000000..6b01b90d --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/dividend/presentation/DividendController.java @@ -0,0 +1,39 @@ +package nexters.payout.apiserver.dividend.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nexters.payout.apiserver.dividend.application.DividendQueryService; +import nexters.payout.apiserver.dividend.application.dto.request.DividendRequest; +import nexters.payout.apiserver.dividend.application.dto.response.MonthlyDividendResponse; +import nexters.payout.apiserver.dividend.application.dto.response.YearlyDividendResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@Slf4j +@RequestMapping("/api/dividends") +public class DividendController implements DividendControllerDocs { + + private final DividendQueryService dividendQueryService; + + @PostMapping("/monthly") + public ResponseEntity> getMonthlyDividends( + @RequestBody @Valid final DividendRequest request + ) { + return ResponseEntity.ok(dividendQueryService.getMonthlyDividends(request)); + } + + @PostMapping("/yearly") + public ResponseEntity getYearlyDividends( + @RequestBody @Valid final DividendRequest request + ) { + return ResponseEntity.ok(dividendQueryService.getYearlyDividends(request)); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/dividend/presentation/DividendControllerDocs.java b/api-server/src/main/java/nexters/payout/apiserver/dividend/presentation/DividendControllerDocs.java new file mode 100644 index 00000000..f6b0fa01 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/dividend/presentation/DividendControllerDocs.java @@ -0,0 +1,60 @@ +package nexters.payout.apiserver.dividend.presentation; + +import io.swagger.v3.oas.annotations.Operation; +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.dividend.application.dto.request.DividendRequest; +import nexters.payout.apiserver.dividend.application.dto.response.MonthlyDividendResponse; +import nexters.payout.apiserver.dividend.application.dto.response.YearlyDividendResponse; +import nexters.payout.core.exception.ErrorResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + +public interface DividendControllerDocs { + + @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 = DividendRequest.class), + examples = { + @ExampleObject(name = "DividendRequestExample", value = "{\"tickerShares\":[{\"ticker\":\"AAPL\",\"share\":3}]}") + }))) + ResponseEntity> getMonthlyDividends(@RequestBody @Valid DividendRequest request); + + + @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 = DividendRequest.class), + examples = { + @ExampleObject(name = "DividendRequestExample", value = "{\"tickerShares\":[{\"ticker\":\"AAPL\",\"share\":3}]}") + }))) + ResponseEntity getYearlyDividends(@RequestBody @Valid DividendRequest request); +} + 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 new file mode 100644 index 00000000..d2e4ac8a --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/StockQueryService.java @@ -0,0 +1,155 @@ +package nexters.payout.apiserver.stock.application; + +import lombok.RequiredArgsConstructor; +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.*; +import nexters.payout.core.exception.error.NotFoundException; +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.repository.StockRepository; +import nexters.payout.domain.stock.domain.service.StockDividendAnalysisService; +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.time.Month; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StockQueryService { + + private final StockRepository stockRepository; + private final DividendRepository dividendRepository; + private final SectorAnalysisService sectorAnalysisService; + private final StockDividendAnalysisService dividendAnalysisService; + + public List searchStock(final String keyword, final Integer pageNumber, final Integer pageSize) { + return stockRepository.findStocksByTickerOrNameWithPriority(keyword, pageNumber, pageSize) + .stream() + .map(StockResponse::from) + .collect(Collectors.toList()); + } + + 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 NotFoundException(String.format("not found ticker [%s]", 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()); + } + + public List analyzeSectorRatio(final SectorRatioRequest request) { + List stockShares = getStockShares(request); + + Map sectorInfoMap = sectorAnalysisService.calculateSectorRatios(stockShares); + + return SectorRatioResponse.fromMap(sectorInfoMap); + } + + public UpcomingDividendResponse getUpcomingDividendStocks(final Sector sector, final int pageNumber, final int pageSize) { + return UpcomingDividendResponse.of( + stockRepository.findUpcomingDividendStock(sector, pageNumber, pageSize) + .stream() + .map(stockDividend -> SingleUpcomingDividendResponse.of( + stockDividend.stock(), + stockDividend.dividend()) + ) + .collect(Collectors.toList()) + ); + } + + public StockDividendYieldResponse getBiggestDividendStocks(final Sector sector, final int pageNumber, final int pageSize) { + return StockDividendYieldResponse.of( + stockRepository.findBiggestDividendYieldStock(InstantProvider.getLastYear(), sector, pageNumber, pageSize) + .stream() + .map(stockDividendYield -> SingleStockDividendYieldResponse.of( + stockDividendYield.stock(), + stockDividendYield.dividendYield()) + ) + .collect(Collectors.toList()) + ); + } + + private List getStockShares(final SectorRatioRequest request) { + List stocks = stockRepository.findAllByTickerIn(getTickers(request)); + + return stocks + .stream() + .map(stock -> new StockShare( + stock, + getTickerShareMap(request).get(stock.getTicker()))) + .collect(Collectors.toList()); + } + + private List getTickers(final SectorRatioRequest request) { + return request.tickerShares() + .stream() + .map(TickerShare::ticker) + .collect(Collectors.toList()); + } + + private Map getTickerShareMap(final SectorRatioRequest request) { + return request.tickerShares() + .stream() + .collect(Collectors.toMap(TickerShare::ticker, TickerShare::share)); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/request/SectorRatioRequest.java b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/request/SectorRatioRequest.java new file mode 100644 index 00000000..887c6896 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/request/SectorRatioRequest.java @@ -0,0 +1,16 @@ +package nexters.payout.apiserver.stock.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 SectorRatioRequest( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Valid + @Size(min = 1) + List tickerShares +) { +} + diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/request/TickerShare.java b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/request/TickerShare.java new file mode 100644 index 00000000..1d975bb3 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/request/TickerShare.java @@ -0,0 +1,16 @@ +package nexters.payout.apiserver.stock.application.dto.request; + +import io.swagger.v3.oas.annotations.Parameter; +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) + @NotEmpty + String ticker, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Min(value = 1) + Integer share +) { +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/DividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/DividendResponse.java new file mode 100644 index 00000000..586b134f --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/DividendResponse.java @@ -0,0 +1,57 @@ +package nexters.payout.apiserver.stock.application.dto.response; + +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.dividend.domain.Dividend; + +import java.time.LocalDate; +import java.time.Month; +import java.util.Collections; +import java.util.List; + +public record DividendResponse( + Double dividendPerShare, + LocalDate upcomingExDividendDate, + LocalDate paymentDate, + Double dividendYield, + List dividendMonths +) { + + public static DividendResponse noDividend() { + return new DividendResponse( + 0.0, + null, + null, + 0.0, + Collections.emptyList() + ); + } + + public static DividendResponse withoutDividendDates( + final Double dividendPerShare, + final Double dividendYield, + final List dividendMonths + ) { + return new DividendResponse( + dividendPerShare, + null, + null, + dividendYield, + dividendMonths + ); + } + + public static DividendResponse fullDividendInfo( + final Dividend dividend, + final Double dividendYield, + final List dividendMonths + ) { + return new DividendResponse( + dividend.getDividend(), + InstantProvider.toLocalDate(dividend.getExDividendDate()).withYear(InstantProvider.getThisYear()), + dividend.getPaymentDate() == null ? null : InstantProvider.toLocalDate(dividend.getPaymentDate()) + .withYear(InstantProvider.getThisYear()), + dividendYield, + dividendMonths + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/SectorRatioResponse.java b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/SectorRatioResponse.java new file mode 100644 index 00000000..ac3226d4 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/SectorRatioResponse.java @@ -0,0 +1,36 @@ +package nexters.payout.apiserver.stock.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +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/stock/application/dto/response/SingleStockDividendYieldResponse.java b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/SingleStockDividendYieldResponse.java new file mode 100644 index 00000000..dcbf7fc3 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/SingleStockDividendYieldResponse.java @@ -0,0 +1,32 @@ +package nexters.payout.apiserver.stock.application.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import nexters.payout.domain.stock.domain.Stock; + +import java.time.Instant; +import java.util.UUID; + +public record SingleStockDividendYieldResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + UUID stockId, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String ticker, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String logoUrl, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double dividendYield, + @JsonIgnore + Instant lastModifiedAt +) { + + public static SingleStockDividendYieldResponse of(final Stock stock, final Double dividendYield) { + return new SingleStockDividendYieldResponse( + stock.getId(), + stock.getTicker(), + stock.getLogoUrl(), + dividendYield, + stock.getLastModifiedAt() + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/SingleUpcomingDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/SingleUpcomingDividendResponse.java new file mode 100644 index 00000000..bdcf50f9 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/SingleUpcomingDividendResponse.java @@ -0,0 +1,32 @@ +package nexters.payout.apiserver.stock.application.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.stock.domain.Stock; + +import java.time.Instant; +import java.util.UUID; + +public record SingleUpcomingDividendResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + UUID stockId, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String ticker, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String logoUrl, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Instant exDividendDate, + @JsonIgnore + Instant lastModifiedAt +) { + public static SingleUpcomingDividendResponse of(final Stock stock, final Dividend dividend) { + return new SingleUpcomingDividendResponse( + stock.getId(), + stock.getTicker(), + stock.getLogoUrl(), + dividend.getExDividendDate(), + dividend.getLastModifiedAt() + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/StockDetailResponse.java b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/StockDetailResponse.java new file mode 100644 index 00000000..8e9a3b44 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/StockDetailResponse.java @@ -0,0 +1,97 @@ +package nexters.payout.apiserver.stock.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.stock.domain.Stock; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Month; +import java.util.List; +import java.util.UUID; + +public record StockDetailResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + UUID stockId, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String ticker, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String companyName, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String sectorName, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String sectorValue, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String exchange, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String industry, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double price, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Integer volume, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String logoUrl, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double dividendPerShare, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + LocalDate exDividendDate, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + LocalDate earliestPaymentDate, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double dividendYield, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List dividendMonths, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Instant lastModifiedAt +) { + + public static StockDetailResponse of( + final Stock stock, final DividendResponse dividendResponse + ) { + return new StockDetailResponse( + stock.getId(), + stock.getTicker(), + stock.getName(), + stock.getSector().getName(), + stock.getSector().name(), + stock.getExchange(), + stock.getIndustry(), + stock.getPrice(), + stock.getVolume(), + stock.getLogoUrl(), + dividendResponse.dividendPerShare(), + dividendResponse.upcomingExDividendDate(), + dividendResponse.paymentDate(), + dividendResponse.dividendYield(), + dividendResponse.dividendMonths(), + stock.getLastModifiedAt() + ); + } + + public static StockDetailResponse of( + final Stock stock, final Dividend dividend, final List dividendMonths, final Double dividendYield + ) { + int thisYear = InstantProvider.getThisYear(); + return new StockDetailResponse( + stock.getId(), + stock.getTicker(), + stock.getName(), + stock.getSector().getName(), + stock.getSector().name(), + stock.getExchange(), + stock.getIndustry(), + stock.getPrice(), + stock.getVolume(), + stock.getLogoUrl(), + dividend.getDividend(), + InstantProvider.toLocalDate(dividend.getExDividendDate()).withYear(thisYear), + dividend.getPaymentDate() == null ? null : + InstantProvider.toLocalDate(dividend.getPaymentDate()).withYear(thisYear), + dividendYield, + dividendMonths, + stock.getLastModifiedAt() + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/StockDividendYieldResponse.java b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/StockDividendYieldResponse.java new file mode 100644 index 00000000..54ee8836 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/StockDividendYieldResponse.java @@ -0,0 +1,18 @@ +package nexters.payout.apiserver.stock.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; +import java.util.List; + +public record StockDividendYieldResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List dividends, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Instant lastModifiedAt +) { + public static StockDividendYieldResponse of(List dividends) { + return dividends.isEmpty() ? new StockDividendYieldResponse(dividends, null) : + new StockDividendYieldResponse(dividends, dividends.get(0).lastModifiedAt()); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/StockResponse.java b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/StockResponse.java new file mode 100644 index 00000000..e4b1c62d --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/StockResponse.java @@ -0,0 +1,44 @@ +package nexters.payout.apiserver.stock.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import nexters.payout.domain.stock.domain.Stock; + +import java.util.UUID; + +public record StockResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + UUID stockId, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String ticker, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String companyName, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String sectorName, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String sectorValue, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String exchange, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String industry, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double price, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Integer volume, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String logoUrl +) { + public static StockResponse from(final Stock stock) { + return new StockResponse( + stock.getId(), + stock.getTicker(), + stock.getName(), + stock.getSector().getName(), + stock.getSector().name(), + stock.getExchange(), + stock.getIndustry(), + stock.getPrice(), + stock.getVolume(), + stock.getLogoUrl() + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/StockShareResponse.java b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/StockShareResponse.java new file mode 100644 index 00000000..73a52eb5 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/StockShareResponse.java @@ -0,0 +1,19 @@ +package nexters.payout.apiserver.stock.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import nexters.payout.domain.stock.domain.service.SectorAnalysisService.StockShare; + +public record StockShareResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + StockResponse stockResponse, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Integer share +) { + + public static StockShareResponse from(final StockShare stockShare) { + return new StockShareResponse( + StockResponse.from(stockShare.stock()), + stockShare.share() + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/UpcomingDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/UpcomingDividendResponse.java new file mode 100644 index 00000000..cd594d62 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/application/dto/response/UpcomingDividendResponse.java @@ -0,0 +1,18 @@ +package nexters.payout.apiserver.stock.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; +import java.util.List; + +public record UpcomingDividendResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List dividends, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Instant lastModifiedAt +) { + public static UpcomingDividendResponse of(List dividends) { + return dividends.isEmpty() ? new UpcomingDividendResponse(dividends, null) : + new UpcomingDividendResponse(dividends, dividends.get(0).lastModifiedAt()); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/presentation/StockController.java b/api-server/src/main/java/nexters/payout/apiserver/stock/presentation/StockController.java new file mode 100644 index 00000000..d1b1c0af --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/presentation/StockController.java @@ -0,0 +1,64 @@ +package nexters.payout.apiserver.stock.presentation; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import nexters.payout.apiserver.stock.application.StockQueryService; +import nexters.payout.apiserver.stock.application.dto.request.SectorRatioRequest; +import nexters.payout.apiserver.stock.application.dto.response.*; +import nexters.payout.domain.stock.domain.Sector; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/stocks") +public class StockController implements StockControllerDocs { + + private final StockQueryService stockQueryService; + + @GetMapping("/search") + public ResponseEntity> searchStock( + @RequestParam @NotEmpty final String keyword, + @RequestParam @NotNull final Integer pageNumber, + @RequestParam @NotNull final Integer pageSize + ) { + return ResponseEntity.ok(stockQueryService.searchStock(keyword, pageNumber, pageSize)); + } + + @GetMapping("/{ticker}") + public ResponseEntity getStockByTicker( + @PathVariable final String ticker + ) { + return ResponseEntity.ok(stockQueryService.getStockByTicker(ticker)); + } + + + @PostMapping("/sector-ratio") + public ResponseEntity> findSectorRatios( + @Valid @RequestBody final SectorRatioRequest request + ) { + return ResponseEntity.ok(stockQueryService.analyzeSectorRatio(request)); + } + + @GetMapping("/ex-dividend-dates/upcoming") + public ResponseEntity getUpComingDividendStocks( + @RequestParam @NotNull final Sector sector, + @RequestParam @NotNull final Integer pageNumber, + @RequestParam @NotNull final Integer pageSize + ) { + return ResponseEntity.ok(stockQueryService.getUpcomingDividendStocks(sector, pageNumber, pageSize)); + } + + @GetMapping("/dividend-yields/highest") + public ResponseEntity getBiggestDividendYieldStocks( + @RequestParam @NotNull final Sector sector, + @RequestParam @NotNull final Integer pageNumber, + @RequestParam @NotNull final Integer pageSize + ) { + return ResponseEntity.ok(stockQueryService.getBiggestDividendStocks(sector, pageNumber, pageSize)); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/stock/presentation/StockControllerDocs.java b/api-server/src/main/java/nexters/payout/apiserver/stock/presentation/StockControllerDocs.java new file mode 100644 index 00000000..d03ac1b2 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/stock/presentation/StockControllerDocs.java @@ -0,0 +1,113 @@ +package nexters.payout.apiserver.stock.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 jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import nexters.payout.apiserver.dividend.application.dto.request.DividendRequest; +import nexters.payout.apiserver.stock.application.dto.request.SectorRatioRequest; +import nexters.payout.apiserver.stock.application.dto.response.*; +import nexters.payout.core.exception.ErrorResponse; +import nexters.payout.domain.stock.domain.Sector; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +public interface StockControllerDocs { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "SUCCESS"), + @ApiResponse(responseCode = "400", description = "BAD REQUEST", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "500", description = "SERVER ERROR", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + }) + @Operation(summary = "티커명/회사명 검색") + ResponseEntity> searchStock( + @Parameter(description = "ticker name or company name of stock ex) APPL, APPLE", required = true) + @RequestParam @NotEmpty String ticker, + @Parameter(description = "page number(start with 1) for pagination", example = "1", required = true) + @RequestParam @NotNull final Integer pageNumber, + @Parameter(description = "page size for pagination", example = "20", required = true) + @RequestParam @NotNull final Integer pageSize + ); + + @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 getStockByTicker( + @Parameter(description = "ticker name of stock", example = "AAPL", required = true) + @PathVariable String ticker + ); + + @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 = SectorRatioRequest.class), + examples = { + @ExampleObject(name = "SectorRatioRequestExample", value = "{\"tickerShares\":[{\"ticker\":\"AAPL\",\"share\":3}]}") + }))) + ResponseEntity> findSectorRatios( + @Valid @RequestBody final SectorRatioRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "SUCCESS"), + @ApiResponse(responseCode = "400", description = "BAD REQUEST", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "500", description = "SERVER ERROR", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + }) + @Operation(summary = "배당락일이 다가오는 주식 리스트") + ResponseEntity getUpComingDividendStocks( + @Parameter(description = "sector value", example = "TECHNOLOGY", required = true) + @RequestParam @NotNull final Sector sector, + @Parameter(description = "page number(start with 1) for pagination", example = "1", required = true) + @RequestParam @NotNull final Integer pageNumber, + @Parameter(description = "page size for pagination", example = "20", required = true) + @RequestParam @NotNull final Integer pageSize + ); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "SUCCESS"), + @ApiResponse(responseCode = "400", description = "BAD REQUEST", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "500", description = "SERVER ERROR", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + }) + @Operation(summary = "배당수익률이 큰 주식 리스트") + ResponseEntity getBiggestDividendYieldStocks( + @Parameter(description = "sector value", example = "TECHNOLOGY", required = true) + @RequestParam @NotNull final Sector sector, + @Parameter(description = "page number(start with 1) for pagination", example = "1", required = true) + @RequestParam @NotNull final Integer pageNumber, + @Parameter(description = "page size for pagination", example = "20", required = true) + @RequestParam @NotNull final Integer pageSize + ); +} + diff --git a/api-server/src/main/resources/application-dev.yml b/api-server/src/main/resources/application-dev.yml new file mode 100644 index 00000000..dcbdb369 --- /dev/null +++ b/api-server/src/main/resources/application-dev.yml @@ -0,0 +1,19 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3306/nexters + username: test + password: test + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + show-sql: true + +springdoc: + swagger-ui: + path: /payout-docs.html + query-config-enabled: true + enabled: true diff --git a/api-server/src/main/resources/application-prod.yml b/api-server/src/main/resources/application-prod.yml new file mode 100644 index 00000000..933816a8 --- /dev/null +++ b/api-server/src/main/resources/application-prod.yml @@ -0,0 +1,30 @@ +spring: + datasource: + url: jdbc:mysql://${DB_HOSTNAME}:${DB_PORT}/${DB_DATABASE} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + show-sql: false + + flyway: + enabled: true + baseline-on-migrate: true + url: jdbc:mysql://${DB_HOSTNAME}:${DB_PORT}/${DB_DATABASE} + user: ${DB_USERNAME} + password: ${DB_PASSWORD} + baseline-version: 0 + + +springdoc: + swagger-ui: + path: /payout-docs.html + query-config-enabled: true + enabled: true diff --git a/api-server/src/main/resources/application-test.yml b/api-server/src/main/resources/application-test.yml new file mode 100644 index 00000000..2d69e7a9 --- /dev/null +++ b/api-server/src/main/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:test;MODE=MySQL + username: sa + password: + + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true + show-sql: true diff --git a/api-server/src/main/resources/application.properties b/api-server/src/main/resources/application.properties deleted file mode 100644 index 900d48b2..00000000 --- a/api-server/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.profiles.include=console-logging, file-logging diff --git a/api-server/src/main/resources/application.yml b/api-server/src/main/resources/application.yml new file mode 100644 index 00000000..027b4e36 --- /dev/null +++ b/api-server/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: test \ No newline at end of file diff --git a/api-server/src/test/java/nexters/dividend/apiserver/DividendApiServerApplicationTests.java b/api-server/src/test/java/nexters/dividend/apiserver/DividendApiServerApplicationTests.java deleted file mode 100644 index fc07be99..00000000 --- a/api-server/src/test/java/nexters/dividend/apiserver/DividendApiServerApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package nexters.dividend.apiserver; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class DividendApiServerApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/api-server/src/test/java/nexters/payout/apiserver/dividend/application/DividendQueryServiceTest.java b/api-server/src/test/java/nexters/payout/apiserver/dividend/application/DividendQueryServiceTest.java new file mode 100644 index 00000000..7ef32034 --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/dividend/application/DividendQueryServiceTest.java @@ -0,0 +1,77 @@ +package nexters.payout.apiserver.dividend.application; + +import nexters.payout.apiserver.dividend.application.dto.request.DividendRequest; +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.dividend.common.GivenFixtureTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static nexters.payout.domain.StockFixture.*; +import static nexters.payout.domain.stock.domain.Sector.*; + +@ExtendWith(MockitoExtension.class) +class DividendQueryServiceTest extends GivenFixtureTest { + + @InjectMocks + private DividendQueryService dividendQueryService; + + @Test + void 사용자의_월간_배당금_정보를_가져온다() { + // given + givenStockAndDividendForMonthly(AAPL, TECHNOLOGY, 2.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + givenStockAndDividendForMonthly(TSLA, UTILITIES, 4.2, 1, 4, 7, 10); + givenStockAndDividendForMonthly(SBUX, CONSUMER_CYCLICAL, 5.0, 6, 12); + double expected = 86.8; + + // when + List actual = dividendQueryService.getMonthlyDividends(request()); + + // 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 + givenStockAndDividendForYearly(AAPL, TECHNOLOGY, 2.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + givenStockAndDividendForYearly(TSLA, UTILITIES, 4.2, 1, 4, 7, 10); + givenStockAndDividendForYearly(SBUX, CONSUMER_CYCLICAL, 5.0, 6, 12); + double totalDividendExpected = 86.8; + double aaplDividendExpected = 60.0; + + // when + YearlyDividendResponse actual = dividendQueryService.getYearlyDividends(request()); + + // then + assertAll( + () -> assertThat(actual.totalDividend()).isEqualTo(totalDividendExpected), + () -> assertThat(actual.dividends() + .stream() + .filter(dividend -> dividend.ticker().equals(AAPL)) + .findFirst().get() + .totalDividend()) + .isEqualTo(aaplDividendExpected) + ); + } + + private DividendRequest request() { + return new DividendRequest(List.of( + new TickerShare(AAPL, 2), + new TickerShare(TSLA, 1), + new TickerShare(SBUX, 1))); + } +} \ No newline at end of file diff --git a/api-server/src/test/java/nexters/payout/apiserver/dividend/common/GivenFixtureTest.java b/api-server/src/test/java/nexters/payout/apiserver/dividend/common/GivenFixtureTest.java new file mode 100644 index 00000000..a13dcebb --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/dividend/common/GivenFixtureTest.java @@ -0,0 +1,93 @@ +package nexters.payout.apiserver.dividend.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.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +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 void givenStockAndDividendForMonthly(String ticker, Sector sector, double dividend, int... cycle) { + Stock stock = StockFixture.createStock(ticker, sector); + given(stockRepository.findByTicker(eq(ticker))).willReturn(Optional.of(stock)); + + for (int month = JANUARY; month <= DECEMBER; month++) { + if (isContain(cycle, month)) { + // 배당 주기에 해당하는 경우 + given(dividendRepository.findAllByTickerAndYearAndMonth( + eq(ticker), + eq(InstantProvider.getLastYear()), + eq(month))) + .willReturn(List.of(DividendFixture.createDividend( + stock.getId(), + dividend, + parseDate(InstantProvider.getLastYear(), month) + ))); + } else { + // 배당 주기에 해당하지 않는 경우 + given(dividendRepository.findAllByTickerAndYearAndMonth( + eq(ticker), + eq(InstantProvider.getLastYear()), + eq(month))) + .willReturn(new ArrayList<>()); + } + } + } + + public void givenStockAndDividendForYearly(String ticker, Sector sector, double dividend, int... cycle) { + Stock stock = StockFixture.createStock(ticker, sector); + given(stockRepository.findByTicker(eq(ticker))).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.findAllByTickerAndYear( + eq(ticker), + eq(InstantProvider.getLastYear()))) + .willReturn(dividends); + } + + 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/dividend/presentation/DividendControllerTest.java b/api-server/src/test/java/nexters/payout/apiserver/dividend/presentation/DividendControllerTest.java new file mode 100644 index 00000000..d86b91f6 --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/dividend/presentation/DividendControllerTest.java @@ -0,0 +1,321 @@ +package nexters.payout.apiserver.dividend.presentation; + +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; +import io.restassured.http.ContentType; +import nexters.payout.apiserver.dividend.application.dto.request.DividendRequest; +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.core.exception.ErrorResponse; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.DividendFixture; +import nexters.payout.domain.StockFixture; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +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.*; + +class DividendControllerTest 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/dividends/monthly") + .then().log().all() + .statusCode(SC_NOT_FOUND) + .extract() + .as(ErrorResponse.class); + } + + @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/dividends/yearly") + .then().log().all() + .statusCode(SC_NOT_FOUND) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 월별_배당금_조회시_배당금이_존재하지_않는_경우_정상적으로_조회된다() { + // given + stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY)); + double expected = 0.0; + + // when + List actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .request() + .body(request()) + .when().post("api/dividends/monthly") + .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 + stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY)); + double expected = 0.0; + + // when + YearlyDividendResponse actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request()) + .when().post("api/dividends/yearly") + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual.totalDividend()).isEqualTo(expected), + () -> assertThat(actual.dividends()).isEmpty() + ); + } + + @Test + void 월별_배당금_조회시_배당금이_존재하는_경우_정상적으로_조회된다() { + // given + stockAndDividendGiven(); + double expected = 13.0; + + // when + List actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request()) + .when().post("api/dividends/monthly") + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>() { + }); + + assertAll( + () -> assertThat(actual + .stream() + .mapToDouble(MonthlyDividendResponse::totalDividend) + .sum()) + .isEqualTo(expected), + () -> assertThat(actual).hasSize(12) + ); + } + + @Test + void 연간_배당금_조회시_배당금이_존재하는_경우_정상적으로_조회된다() { + // given + stockAndDividendGiven(); + double expected = 13.0; + + // when + YearlyDividendResponse actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request()) + .when().post("api/dividends/yearly") + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual.totalDividend()).isEqualTo(expected), + () -> assertThat(actual.dividends().size()).isEqualTo(2) + ); + } + + @Test + void 월별_배당금_조회시_빈_리스트로_요청한_경우_400_예외가_발생한다() { + // given + DividendRequest request = new DividendRequest(new ArrayList<>()); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/dividends/monthly") + .then().log().all() + .statusCode(SC_BAD_REQUEST) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 연간_배당금_조회시_빈_리스트로_요청한_경우_400_예외가_발생한다() { + // given + DividendRequest request = new DividendRequest(new ArrayList<>()); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/dividends/yearly") + .then().log().all() + .statusCode(SC_BAD_REQUEST) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 월간_배당금_조회시_티커가_빈문자열이면_예외가_발생한다() { + // given + DividendRequest request = new DividendRequest(List.of(new TickerShare("", 2))); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/dividends/monthly") + .then().log().all() + .statusCode(SC_BAD_REQUEST) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 연간_배당금_조회시_티커가_빈문자열이면_예외가_발생한다() { + // given + DividendRequest request = new DividendRequest(List.of(new TickerShare("", 2))); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/dividends/yearly") + .then().log().all() + .statusCode(SC_BAD_REQUEST) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 월간_배당금_조회시__종목_소유_개수가_0개인_경우_400_예외가_발생한다() { + // given + DividendRequest request = new DividendRequest(List.of(new TickerShare(AAPL, 0))); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/dividends/monthly") + .then().log().all() + .statusCode(SC_BAD_REQUEST) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 연간_배당금_조회시__종목_소유_개수가_0개인_경우_400_예외가_발생한다() { + // given + DividendRequest request = new DividendRequest(List.of(new TickerShare(AAPL, 0))); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/dividends/yearly") + .then().log().all() + .statusCode(SC_BAD_REQUEST) + .extract() + .as(ErrorResponse.class); + } + + private DividendRequest request() { + return new DividendRequest(List.of( + new TickerShare(AAPL, 2), + new TickerShare(TSLA, 1) + )); + } + + private void stockAndDividendGiven() { + 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))); + } + + private Instant parseDate(int year, int month) { + LocalDate date = LocalDate.of(year, month, 1); + ZonedDateTime zonedDateTime = date.atStartOfDay(ZoneId.of("UTC")); + return zonedDateTime.toInstant(); + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..fff4ac0b --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/stock/application/StockQueryServiceTest.java @@ -0,0 +1,202 @@ +package nexters.payout.apiserver.stock.application; + +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.*; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.apiserver.stock.application.dto.response.SingleUpcomingDividendResponse; +import nexters.payout.apiserver.stock.application.dto.response.SectorRatioResponse; +import nexters.payout.apiserver.stock.application.dto.response.StockDetailResponse; +import nexters.payout.apiserver.stock.application.dto.response.StockResponse; +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.infra.dto.StockDividendDto; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.domain.repository.StockRepository; +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.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 java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Month; +import java.util.List; +import java.util.Optional; + +import static java.time.ZoneOffset.UTC; +import static nexters.payout.domain.StockFixture.*; +import static nexters.payout.domain.stock.domain.Sector.TECHNOLOGY; +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; + +@ExtendWith(MockitoExtension.class) +class StockQueryServiceTest { + + @InjectMocks + private StockQueryService stockQueryService; + + @Mock + private StockRepository stockRepository; + @Mock + private DividendRepository dividendRepository; + @Spy + private SectorAnalysisService sectorAnalysisService; + @Spy + private StockDividendAnalysisService stockDividendAnalysisService; + + @Test + void 검색된_종목_정보를_정상적으로_반환한다() { + // given + given(stockRepository.findStocksByTickerOrNameWithPriority(any(), any(), any())).willReturn(List.of(StockFixture.createStock(AAPL, Sector.TECHNOLOGY))); + + // when + List actual = stockQueryService.searchStock("A", 1, 2); + + // then + assertAll( + () -> assertThat(actual.get(0).ticker()).isEqualTo(AAPL), + () -> assertThat(actual.get(0).sectorName()).isEqualTo(Sector.TECHNOLOGY.getName()), + () -> assertThat(actual.get(0).logoUrl()).isEqualTo("") + ); + } + + @Test + void 종목_상세_정보를_정상적으로_반환한다() { + // given + int lastYear = LocalDate.now(UTC).getYear() - 1; + int expectedMonth = 3; + Instant exDividendDate = LocalDate.of(lastYear, expectedMonth, 1).atStartOfDay().toInstant(UTC); + Double expectedPrice = 2.0; + Double expectedDividend = 0.5; + Stock aapl = StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 2.0); + Dividend dividend = DividendFixture.createDividend(aapl.getId(), 0.5, exDividendDate); + + given(stockRepository.findByTicker(any())).willReturn(Optional.of(aapl)); + given(dividendRepository.findAllByStockId(any())).willReturn(List.of(dividend)); + + // when + StockDetailResponse actual = stockQueryService.getStockByTicker(aapl.getTicker()); + + // then + assertAll( + () -> assertThat(actual.ticker()).isEqualTo(aapl.getTicker()), + () -> assertThat(actual.industry()).isEqualTo(aapl.getIndustry()), + () -> assertThat(actual.dividendYield()).isEqualTo(expectedDividend / expectedPrice), + () -> assertThat(actual.dividendMonths()).isEqualTo(List.of(Month.of(expectedMonth))) + ); + } + + @Test + void 종목_상세_정보의_배당날짜를_올해기준으로_반환한다() { + // given + int expectedMonth = 3; + int expectedDayOfMonth = 1; + int lastYear = LocalDate.now().getYear() - 1; + Instant exDividendDate = LocalDate.of(lastYear, 3, 1).atStartOfDay().toInstant(UTC); + Stock appl = StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 2.0); + Dividend dividend = DividendFixture.createDividendWithPaymentDate(appl.getId(), 0.5, exDividendDate); + + given(stockRepository.findByTicker(any())).willReturn(Optional.of(appl)); + given(dividendRepository.findAllByStockId(any())).willReturn(List.of(dividend)); + + // when + StockDetailResponse actual = stockQueryService.getStockByTicker(appl.getTicker()); + + // then + assertThat(actual.earliestPaymentDate()).isEqualTo(LocalDate.of(lastYear + 1, expectedMonth, expectedDayOfMonth)); + } + + @Test + void 섹터_정보를_정상적으로_반환한다() { + // given + SectorRatioRequest request = new SectorRatioRequest(List.of(new TickerShare(AAPL, 2), new TickerShare(TSLA, 3))); + Stock appl = StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 4.0); + Stock tsla = StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL, 2.2); + List stocks = List.of(appl, tsla); + + given(stockRepository.findAllByTickerIn(any())).willReturn(stocks); + + List expected = List.of( + new SectorRatioResponse( + Sector.TECHNOLOGY.getName(), + Sector.TECHNOLOGY.name(), + 0.547945205479452, + List.of(new StockShareResponse( + StockResponse.from(appl), + 2 + )) + ), + new SectorRatioResponse( + Sector.CONSUMER_CYCLICAL.getName(), + Sector.CONSUMER_CYCLICAL.name(), + 0.4520547945205479, + List.of(new StockShareResponse( + StockResponse.from(tsla), + 3 + )) + ) + ); + + // when + List actual = stockQueryService.analyzeSectorRatio(request); + + // then + assertThat(actual).containsExactlyInAnyOrderElementsOf(expected); + } + + @Test + void 배당락일이_다가오는_주식_리스트를_가져온다() { + // given + Stock stock = StockFixture.createStock(AAPL, TECHNOLOGY); + Dividend expected = DividendFixture.createDividendWithExDividendDate(stock.getId(), LocalDateTime.now().plusDays(1).toInstant(UTC)); + given(stockRepository.findUpcomingDividendStock(TECHNOLOGY, 1, 10)) + .willReturn(List.of(new StockDividendDto(stock, expected))); + + // when + List actual = stockQueryService.getUpcomingDividendStocks(TECHNOLOGY, 1, 10).dividends(); + + // then + assertAll( + () -> assertThat(actual.size()).isEqualTo(1), + () -> assertThat(actual.get(0).exDividendDate()).isEqualTo(expected.getExDividendDate()), + () -> assertThat(actual.get(0).ticker()).isEqualTo(stock.getTicker()) + ); + } + + @Test + void 배당_수익률이_큰_순서대로_주식_리스트를_가져온다() { + // given + Stock expected = StockFixture.createStock(AAPL, TECHNOLOGY, 2.0); + Stock tsla = StockFixture.createStock(TSLA, TECHNOLOGY, 3.0); + given(stockRepository.findBiggestDividendYieldStock(InstantProvider.getLastYear(), TECHNOLOGY, 1, 10)) + .willReturn(List.of( + new StockDividendYieldDto(expected, 5.0), + new StockDividendYieldDto(tsla, 4.0)) + ); + Double expectedAaplDividendYield = 5.0; + + // when + List actual = stockQueryService.getBiggestDividendStocks(TECHNOLOGY, 1, 10).dividends(); + + + // then + assertAll( + () -> assertThat(actual.size()).isEqualTo(2), + () -> assertThat(actual.get(0).stockId()).isEqualTo(expected.getId()), + () -> assertThat(actual.get(0).dividendYield()).isEqualTo(expectedAaplDividendYield) + ); + } +} \ No newline at end of file diff --git a/api-server/src/test/java/nexters/payout/apiserver/stock/common/IntegrationTest.java b/api-server/src/test/java/nexters/payout/apiserver/stock/common/IntegrationTest.java new file mode 100644 index 00000000..ae5aa6bf --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/stock/common/IntegrationTest.java @@ -0,0 +1,36 @@ +package nexters.payout.apiserver.stock.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/stock/presentation/integration/StockControllerTest.java b/api-server/src/test/java/nexters/payout/apiserver/stock/presentation/integration/StockControllerTest.java new file mode 100644 index 00000000..68846ad3 --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/stock/presentation/integration/StockControllerTest.java @@ -0,0 +1,512 @@ +package nexters.payout.apiserver.stock.presentation.integration; + +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; +import io.restassured.http.ContentType; +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.*; +import nexters.payout.apiserver.stock.common.IntegrationTest; +import nexters.payout.core.exception.ErrorResponse; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.DividendFixture; +import nexters.payout.domain.StockFixture; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Month; +import java.util.List; + +import static java.time.ZoneOffset.UTC; +import static nexters.payout.core.time.InstantProvider.*; +import static nexters.payout.domain.StockFixture.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class StockControllerTest extends IntegrationTest { + @Test + void 검색키워드가_빈값인_경우_400_예외가_발생한다() { + // given + Stock apdd = StockFixture.createStock("APDD", "DDDD"); + + stockRepository.save(apdd); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get("api/stocks/search?keyword=") + .then().log().all() + .statusCode(400) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 티커는_앞자리부터_검색_회사명은_중간에서도_검색_가능하다() { + // given + Stock apdd = StockFixture.createStock("APDD", "DDDD"); + Stock abcd = StockFixture.createStock("ABCD", "APPLE"); + + stockRepository.save(apdd); + stockRepository.save(abcd); + + // when + List actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get("api/stocks/search?keyword=AP&pageNumber=1&pageSize=20") + .then().log().all() + .statusCode(200) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual).hasSize(2), + () -> assertThat(actual).containsExactlyInAnyOrderElementsOf( + List.of( + new StockResponse(apdd.getId(), apdd.getTicker(), apdd.getName(), apdd.getSector().getName(), apdd.getSector().name(), apdd.getExchange(), apdd.getIndustry(), apdd.getPrice(), apdd.getVolume(), apdd.getLogoUrl()), + new StockResponse(abcd.getId(), abcd.getTicker(), abcd.getName(), abcd.getSector().getName(), abcd.getSector().name(), abcd.getExchange(), abcd.getIndustry(), abcd.getPrice(), abcd.getVolume(), abcd.getLogoUrl()) + ) + ) + ); + } + + @Test + void 티커_기반_검색_1순위_회사명_기반_검색이_2순위이다() { + // given + Stock apdd = StockFixture.createStock("APDD", "DDDD"); + Stock abcd = StockFixture.createStock("ABCD", "APPLE"); + + stockRepository.save(apdd); + stockRepository.save(abcd); + + // when + List actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get("api/stocks/search?keyword=AP&pageNumber=1&pageSize=20") + .then().log().all() + .statusCode(200) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual).hasSize(2), + () -> assertThat(actual).isEqualTo( + List.of( + new StockResponse(apdd.getId(), apdd.getTicker(), apdd.getName(), apdd.getSector().getName(), apdd.getSector().name(), apdd.getExchange(), apdd.getIndustry(), apdd.getPrice(), apdd.getVolume(), apdd.getLogoUrl()), + new StockResponse(abcd.getId(), abcd.getTicker(), abcd.getName(), abcd.getSector().getName(), abcd.getSector().name(), abcd.getExchange(), abcd.getIndustry(), abcd.getPrice(), abcd.getVolume(), abcd.getLogoUrl()) + ) + ) + ); + } + + @Test + void 검색_결과는_알파벳_순으로_정렬한다() { + // given + Stock dddd = StockFixture.createStock("DDDD", "DDDDA"); + Stock aaaa = StockFixture.createStock("AAAA", "AAADA"); + + stockRepository.save(dddd); + stockRepository.save(aaaa); + + // when + List actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get("api/stocks/search?keyword=DA&pageNumber=1&pageSize=20") + .then().log().all() + .statusCode(200) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual).hasSize(2), + () -> assertThat(actual).containsExactlyInAnyOrderElementsOf( + List.of( + new StockResponse(aaaa.getId(), aaaa.getTicker(), aaaa.getName(), aaaa.getSector().getName(), aaaa.getSector().name(), aaaa.getExchange(), aaaa.getIndustry(), aaaa.getPrice(), aaaa.getVolume(), aaaa.getLogoUrl()), + new StockResponse(dddd.getId(), dddd.getTicker(), dddd.getName(), dddd.getSector().getName(), dddd.getSector().name(), dddd.getExchange(), dddd.getIndustry(), dddd.getPrice(), dddd.getVolume(), dddd.getLogoUrl()) + ) + ) + ); + } + + @Test + void 종목_조회시_티커를_찾을수없는경우_404_예외가_발생한다() { + // given + stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get("api/stocks/aaaaa") + .then().log().all() + .statusCode(404) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 종목_조회시_종목의_정보가_정상적으로_조회된다() { + // given + stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + + // when, then + StockDetailResponse stockDetailResponse = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get("api/stocks/TSLA") + .then().log().all() + .statusCode(200) + .extract() + .as(new TypeRef<>() { + }); + + assertAll( + () -> assertThat(stockDetailResponse.ticker()).isEqualTo(TSLA), + () -> assertThat(stockDetailResponse.sectorName()).isEqualTo(Sector.CONSUMER_CYCLICAL.getName()) + ); + } + + @Test + void 종목_조회시_종목의_현재가가_존재하지않으면_배당수익률은_0으로_조회된다() { + // given + 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)); + + // when, then + StockDetailResponse stockDetailResponse = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get("api/stocks/TSLA") + .then().log().all() + .statusCode(200) + .extract() + .as(new TypeRef<>() { + }); + + 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)) + ); + } + + + @Test + void 섹터_분석시_빈_리스트로_요청한_경우_400_예외가_발생한다() { + // given + SectorRatioRequest request = new SectorRatioRequest(List.of()); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/stocks/sector-ratio") + .then().log().all() + .statusCode(400) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 섹터_분석시_티커가_빈문자열이면_예외가_발생한다() { + // given + SectorRatioRequest request = new SectorRatioRequest(List.of(new TickerShare("", 1))); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/stocks/sector-ratio") + .then().log().all() + .statusCode(400) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 섹터_분석시_종목_소유_개수가_0개인_경우_400_예외가_발생한다() { + // given + SectorRatioRequest request = new SectorRatioRequest(List.of(new TickerShare(AAPL, 0))); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/stocks/sector-ratio") + .then().log().all() + .statusCode(400) + .extract() + .as(ErrorResponse.class); + } + + + @Test + void 섹터_분석시_티커가_1개_이상일_경우_정상적으로_동작한다() { + // given + SectorRatioRequest request = new SectorRatioRequest(List.of(new TickerShare(AAPL, 2))); + Stock stock = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 5.0)); + + // when + List actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/stocks/sector-ratio") + .then().log().all() + .statusCode(200) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual).hasSize(1), + () -> assertThat(actual.get(0).sectorName()).isEqualTo("Technology"), + () -> assertThat(actual.get(0).sectorRatio()).isEqualTo(1.0), + () -> assertThat(actual.get(0).stockShares().get(0).stockResponse().ticker()).isEqualTo(AAPL) + ); + } + + @Test + void 섹터_분석시_선택한_종목의_배당금이_존재하지_않아도_정상적으로_동작한다() { + // given + SectorRatioRequest request = new SectorRatioRequest(List.of(new TickerShare(AAPL, 2))); + Stock stock = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 5.0)); + + // when + List actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/stocks/sector-ratio") + .then().log().all() + .statusCode(200) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual).hasSize(1), + () -> assertThat(actual.get(0).sectorName()).isEqualTo("Technology"), + () -> assertThat(actual.get(0).sectorRatio()).isEqualTo(1.0), + () -> assertThat(actual.get(0).stockShares().get(0).stockResponse().ticker()).isEqualTo(AAPL) + ); + } + + @Test + void 배당락일이_다가오는_주식_리스트를_가져온다() { + // given + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 5.0)); + dividendRepository.save(DividendFixture.createDividend( + aapl.getId(), + 25.0, + LocalDateTime.now().plusDays(1).toInstant(UTC) + )); + LocalDateTime expected = LocalDateTime.now().plusDays(1); + + // when + UpcomingDividendResponse actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get("api/stocks/ex-dividend-dates/upcoming?sector=TECHNOLOGY&pageNumber=1&pageSize=20") + .then().log().all() + .statusCode(200) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual.dividends().size()).isEqualTo(1), + () -> assertThat(actual.dividends().get(0).stockId()).isEqualTo(aapl.getId()), + () -> assertThat(getYear(actual.dividends().get(0).exDividendDate())).isEqualTo(expected.getYear()), + () -> assertThat(getMonth(actual.dividends().get(0).exDividendDate())).isEqualTo(expected.getMonthValue()), + () -> assertThat(getDayOfMonth(actual.dividends().get(0).exDividendDate())).isEqualTo(expected.getDayOfMonth()) + ); + } + + @Test + void 배당락일이_다가오는_주식_리스트는_배당락일이_가까운_순서대로_정렬된다() { + // given + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 5.0)); + Stock tsla = stockRepository.save(StockFixture.createStock(TSLA, Sector.TECHNOLOGY, 5.0)); + dividendRepository.save(DividendFixture.createDividend( + aapl.getId(), + 25.0, + LocalDateTime.now().plusDays(2).toInstant(UTC) + )); + dividendRepository.save(DividendFixture.createDividend( + tsla.getId(), + 30.0, + LocalDateTime.now().plusDays(1).toInstant(UTC) + )); + LocalDateTime expected = LocalDateTime.now().plusDays(1); + + // when + UpcomingDividendResponse actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get("api/stocks/ex-dividend-dates/upcoming?sector=TECHNOLOGY&pageNumber=1&pageSize=20") + .then().log().all() + .statusCode(200) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual.dividends().size()).isEqualTo(2), + () -> assertThat(actual.dividends().get(0).stockId()).isEqualTo(tsla.getId()), + () -> assertThat(getYear(actual.dividends().get(0).exDividendDate())).isEqualTo(expected.getYear()), + () -> assertThat(getMonth(actual.dividends().get(0).exDividendDate())).isEqualTo(expected.getMonthValue()), + () -> assertThat(getDayOfMonth(actual.dividends().get(0).exDividendDate())).isEqualTo(expected.getDayOfMonth()) + ); + } + + @Test + void 배당_수익률이_큰_순서대로_주식_리스트를_가져온다() { + // given + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 10.0)); + Stock tsla = stockRepository.save(StockFixture.createStock(TSLA, Sector.TECHNOLOGY, 20.0)); + dividendRepository.save(DividendFixture.createDividend( + aapl.getId(), + 8.0, + LocalDate.of(InstantProvider.getLastYear(), 3, 1).atStartOfDay().toInstant(UTC) + )); + dividendRepository.save(DividendFixture.createDividend( + tsla.getId(), + 5.0, + LocalDate.of(InstantProvider.getLastYear(), 3, 1).atStartOfDay().toInstant(UTC) + )); + dividendRepository.save(DividendFixture.createDividend( + tsla.getId(), + 5.0, + LocalDate.of(InstantProvider.getLastYear(), 6, 1).atStartOfDay().toInstant(UTC) + )); + dividendRepository.save(DividendFixture.createDividend( + tsla.getId(), + 5.0, + LocalDate.of(InstantProvider.getLastYear() - 1, 6, 1).atStartOfDay().toInstant(UTC) + )); + + Double expectedAaplDividendYield = 0.8; + Double expectedTslaDividendYield = 0.5; + + // when + StockDividendYieldResponse actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get("api/stocks/dividend-yields/highest?sector=TECHNOLOGY&pageNumber=1&pageSize=20") + .then().log().all() + .statusCode(200) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual.dividends().size()).isEqualTo(2), + () -> assertThat(actual.dividends().get(0).dividendYield()).isEqualTo(expectedAaplDividendYield), + () -> assertThat(actual.dividends().get(0).ticker()).isEqualTo(aapl.getTicker()), + () -> assertThat(actual.dividends().get(1).dividendYield()).isEqualTo(expectedTslaDividendYield) + ); + } + + @Test + void 연간_배당금이_없는_주식은_배당_수익률_계산시_포함되지_않는다() { + // given + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 5.0)); + stockRepository.save(StockFixture.createStock(TSLA, Sector.TECHNOLOGY, 0.0)); + dividendRepository.save(DividendFixture.createDividend( + aapl.getId(), + 2.5, + LocalDate.of(InstantProvider.getLastYear(), 3, 1).atStartOfDay().toInstant(UTC) + )); + Double expected = 0.5; + + // when + StockDividendYieldResponse actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get("api/stocks/dividend-yields/highest?sector=TECHNOLOGY&pageNumber=1&pageSize=20") + .then().log().all() + .statusCode(200) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual.dividends().size()).isEqualTo(1), + () -> assertThat(actual.dividends().get(0).dividendYield()).isEqualTo(expected) + ); + } + + @Test + void 배당_수익률이_0_9를_넘어가는_주식은_배당_수익률_계산시_포함되지_않는다() { + // given + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 5.0)); + stockRepository.save(StockFixture.createStock(TSLA, Sector.TECHNOLOGY, 0.0)); + dividendRepository.save(DividendFixture.createDividend( + aapl.getId(), + 10.0, + LocalDate.of(InstantProvider.getLastYear(), 3, 1).atStartOfDay().toInstant(UTC) + )); + + // when + StockDividendYieldResponse actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .when().get("api/stocks/dividend-yields/highest?sector=TECHNOLOGY&pageNumber=1&pageSize=20") + .then().log().all() + .statusCode(200) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual.dividends().size()).isEqualTo(0) + ); + } +} \ No newline at end of file diff --git a/batch/Dockerfile b/batch/Dockerfile new file mode 100644 index 00000000..3eaf668e --- /dev/null +++ b/batch/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:17 + +ARG JAR_FILE=build/libs/batch.jar +COPY ${JAR_FILE} batch.jar +ENTRYPOINT ["java","-jar","-Dspring.profiles.active=prod,file-logging","-Duser.timezone=UTC","/batch.jar"] \ No newline at end of file diff --git a/batch/build.gradle b/batch/build.gradle index 7587ea9a..99ac609f 100644 --- a/batch/build.gradle +++ b/batch/build.gradle @@ -4,11 +4,40 @@ plugins { id 'io.spring.dependency-management' version '1.1.4' } +group = 'nexters' +version = '0.0.1-SNAPSHOT' + dependencies { - implementation(project(":core")) + // include other modules + implementation project(":domain") + implementation project(":core") + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Spring data jpa + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // Spring boot implementation 'org.springframework.boot:spring-boot-starter' + + // Webflux + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // Spring boot starter testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation(testFixtures(project(":domain"))) + + // MySQL + runtimeOnly 'com.mysql:mysql-connector-j' + + // H2 Database + runtimeOnly 'com.h2database:h2' + + // await + testImplementation 'org.awaitility:awaitility:4.2.0' } tasks.named('test') { diff --git a/batch/src/main/java/nexters/dividend/batch/DividendBatchApplication.java b/batch/src/main/java/nexters/dividend/batch/DividendBatchApplication.java deleted file mode 100644 index 2159dda8..00000000 --- a/batch/src/main/java/nexters/dividend/batch/DividendBatchApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package nexters.dividend.batch; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class DividendBatchApplication { - - public static void main(String[] args) { - SpringApplication.run(DividendBatchApplication.class, args); - } - -} diff --git a/batch/src/main/java/nexters/payout/batch/PayoutBatchApplication.java b/batch/src/main/java/nexters/payout/batch/PayoutBatchApplication.java new file mode 100644 index 00000000..0e8f8aa8 --- /dev/null +++ b/batch/src/main/java/nexters/payout/batch/PayoutBatchApplication.java @@ -0,0 +1,21 @@ +package nexters.payout.batch; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; + +@ConfigurationPropertiesScan +@SpringBootApplication(scanBasePackages = { + "nexters.payout.core", + "nexters.payout.domain", + "nexters.payout.batch" +}) +@EnableScheduling +public class PayoutBatchApplication { + + public static void main(String[] args) { + SpringApplication.run(PayoutBatchApplication.class, args); + } + +} diff --git a/batch/src/main/java/nexters/payout/batch/application/DividendBatchService.java b/batch/src/main/java/nexters/payout/batch/application/DividendBatchService.java new file mode 100644 index 00000000..a277fe10 --- /dev/null +++ b/batch/src/main/java/nexters/payout/batch/application/DividendBatchService.java @@ -0,0 +1,67 @@ +package nexters.payout.batch.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import nexters.payout.batch.application.FinancialClient.DividendData; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.dividend.application.DividendCommandService; +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.domain.repository.StockRepository; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class DividendBatchService { + + private final FinancialClient financialClient; + private final DividendCommandService dividendCommandService; + private final StockRepository stockRepository; + + /** + * UTC 시간대 기준으로 매주 월요일 새벽 4시에 작년 한해 동안의 배당금 정보를 갱신합니다. + */ + @Scheduled(cron = "${schedules.cron.dividend.past}", zone = "UTC") + public void updatePastDividendInfo() { + log.info("update past dividend start.."); + handleDividendData(financialClient.getPastDividendList()); + log.info("update past dividend end.."); + } + + /** + * 어제 삽입된 미래 배당금 정보를 삭제하고, UTC 시간대 기준으로 매일 새벽 4시에 현재 날짜로부터 3개월 간의 다가오는 배당금 정보를 갱신합니다. + */ + @Scheduled(cron = "${schedules.cron.dividend.future}", zone = "UTC") + public void updateUpcomingDividendInfo() { + log.info("update upcoming dividend start.."); + dividendCommandService.deleteInvalidDividend(); + handleDividendData(financialClient.getUpcomingDividendList()); + log.info("update upcoming dividend end.."); + } + + private void saveOrUpdateDividendData(final Stock stock, final DividendData dividendData) { + try { + dividendCommandService.saveOrUpdate( + stock.getId(), + Dividend.create( + stock.getId(), dividendData.dividend(), dividendData.exDividendDate(), + dividendData.paymentDate(), dividendData.declarationDate() + ) + ); + } catch (Exception e) { + log.error("fail to save(update) dividend: " + dividendData); + log.error(e.getMessage()); + } + } + + private void handleDividendData(List dividendResponses) { + for (DividendData dividendData : dividendResponses) { + stockRepository.findByTicker(dividendData.symbol()) + .ifPresent(stock -> saveOrUpdateDividendData(stock, dividendData)); + } + } +} diff --git a/batch/src/main/java/nexters/payout/batch/application/FinancialClient.java b/batch/src/main/java/nexters/payout/batch/application/FinancialClient.java new file mode 100644 index 00000000..5f01f627 --- /dev/null +++ b/batch/src/main/java/nexters/payout/batch/application/FinancialClient.java @@ -0,0 +1,47 @@ +package nexters.payout.batch.application; + +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; + +import java.time.Instant; +import java.util.List; + +public interface FinancialClient { + + List getLatestStockList(); + + List getPastDividendList(); + + List getUpcomingDividendList(); + + record StockData( + String ticker, + String name, + String exchange, + Sector sector, + String industry, + Double price, + Integer volume, + Integer avgVolume + ) { + Stock toDomain() { + return new Stock(ticker, name, sector, exchange, industry, price, volume, null); + } + + Stock toDomain(String logoUrl) { + return new Stock(ticker, name, sector, exchange, industry, price, volume, logoUrl); + } + } + + record DividendData( + Instant exDividendDate, + String label, + Double adjDividend, + String symbol, + Double dividend, + Instant recordDate, + Instant paymentDate, + Instant declarationDate + ) { + } +} \ No newline at end of file diff --git a/batch/src/main/java/nexters/payout/batch/application/StockBatchService.java b/batch/src/main/java/nexters/payout/batch/application/StockBatchService.java new file mode 100644 index 00000000..8aed70e9 --- /dev/null +++ b/batch/src/main/java/nexters/payout/batch/application/StockBatchService.java @@ -0,0 +1,45 @@ +package nexters.payout.batch.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nexters.payout.batch.application.FinancialClient.StockData; +import nexters.payout.domain.stock.application.StockCommandService; +import nexters.payout.domain.stock.domain.repository.StockRepository; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Service +public class StockBatchService { + + private final FinancialClient financialClient; + private final StockCommandService stockCommandService; + private final StockLogo stockLogo; + private final StockRepository stockRepository; + + /** + * UTC 시간대 기준 매일 자정에 모든 종목의 현재가와 거래량을 업데이트합니다. + */ + @Scheduled(cron = "${schedules.cron.stock}", zone = "UTC") + void run() { + log.info("update stock start.."); + List stockList = financialClient.getLatestStockList(); + + for (StockData stockData : stockList) { + try { + stockRepository.findByTicker(stockData.ticker()).ifPresentOrElse( + existing -> stockCommandService.update(stockData.ticker(), stockData.toDomain()), + () -> stockCommandService.create(stockData.toDomain(stockLogo.getLogoUrl(stockData.ticker()))) + ); + } catch (Exception e) { + log.error("fail to save(update) stock: " + stockData); + log.error(e.getMessage()); + } + } + + log.info("update stock end.."); + } +} diff --git a/batch/src/main/java/nexters/payout/batch/application/StockLogo.java b/batch/src/main/java/nexters/payout/batch/application/StockLogo.java new file mode 100644 index 00000000..46eaa42a --- /dev/null +++ b/batch/src/main/java/nexters/payout/batch/application/StockLogo.java @@ -0,0 +1,5 @@ +package nexters.payout.batch.application; + +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 new file mode 100644 index 00000000..3ed1fea1 --- /dev/null +++ b/batch/src/main/java/nexters/payout/batch/infra/fmp/FmpDto.java @@ -0,0 +1,46 @@ +package nexters.payout.batch.infra.fmp; + + +import lombok.Getter; +import lombok.Setter; +import nexters.payout.batch.application.FinancialClient.DividendData; +import nexters.payout.core.time.DateFormat; + +@Getter +class FmpStockData { + String symbol; + String companyName; + String exchangeShortName; + Double price; + Integer volume; + @Setter + String sector; + String industry; +} + +record FmpVolumeData( + String symbol, + Integer volume, + Integer avgVolume +) { +} + +record FmpDividendData( + String date, + String label, + Double adjDividend, + String symbol, + Double dividend, + String recordDate, + String paymentDate, + String declarationDate +) { + DividendData toDividendData() { + return new DividendData( + DateFormat.parseInstant(date), + label, adjDividend, symbol, dividend, + DateFormat.parseInstant(recordDate), + DateFormat.parseInstant(paymentDate), + DateFormat.parseInstant(declarationDate)); + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..70d11f2f --- /dev/null +++ b/batch/src/main/java/nexters/payout/batch/infra/fmp/FmpFinancialClient.java @@ -0,0 +1,206 @@ +package nexters.payout.batch.infra.fmp; + +import lombok.extern.slf4j.Slf4j; +import nexters.payout.batch.application.FinancialClient; +import nexters.payout.core.time.DateFormat; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.stock.domain.Exchange; +import nexters.payout.domain.stock.domain.Sector; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.time.ZoneOffset.UTC; +import static nexters.payout.domain.stock.domain.Sector.ETC; +import static nexters.payout.domain.stock.domain.Sector.ETF; + +@Slf4j +@Service +public class FmpFinancialClient implements FinancialClient { + + private final WebClient fmpWebClient; + private final FmpProperties fmpProperties; + private final static int MAX_LIMIT = 1000000; + + FmpFinancialClient(final FmpProperties fmpProperties) { + this.fmpProperties = fmpProperties; + this.fmpWebClient = WebClient.builder() + .baseUrl(fmpProperties.getBaseUrl()) + .build(); + } + + @Override + public List getLatestStockList() { + Map stockDataMap = Stream.concat( + Sector.getNames() + .stream() + .filter(sector -> !(sector.equals(ETC.getName()) || sector.equals(ETF.getName()))) + .flatMap(this::fetchStockList) + , fetchEtfStockList()) + .collect(Collectors.toMap(FmpStockData::getSymbol, Function.identity(), (first, second) -> first)); + + Map volumeDataMap = Arrays + .stream(Exchange.values()) + .flatMap(exchange -> fetchVolumeList(exchange).stream()) + .collect(Collectors.toMap(FmpVolumeData::symbol, fmpVolumeData -> fmpVolumeData)); + + return stockDataMap.entrySet() + .stream() + .map(entry -> { + String tickerName = entry.getKey(); + FmpStockData fmpStockData = entry.getValue(); + FmpVolumeData fmpVolumeData = volumeDataMap + .getOrDefault(tickerName, new FmpVolumeData(tickerName, null, null)); + + return new StockData( + tickerName, + fmpStockData.getCompanyName(), + fmpStockData.getExchangeShortName(), + Sector.fromName(fmpStockData.getSector()), + fmpStockData.getIndustry(), + fmpStockData.getPrice(), + fmpVolumeData.volume(), + fmpVolumeData.avgVolume() + ); + }) + .toList(); + } + + private Stream fetchStockList(final String sector) { + return fmpWebClient.get() + .uri(uriBuilder -> uriBuilder + .path(fmpProperties.getStockScreenerPath()) + .queryParam("apikey", fmpProperties.getApiKey()) + .queryParam("exchange", Exchange.getNames()) + .queryParam("sector", sector) + .queryParam("limit", MAX_LIMIT) + .build()) + .retrieve() + .bodyToFlux(FmpStockData.class) + .collectList() + .block() + .stream(); + } + + private Stream fetchEtfStockList() { + return fmpWebClient.get() + .uri(uriBuilder -> uriBuilder + .path(fmpProperties.getStockScreenerPath()) + .queryParam("apikey", fmpProperties.getApiKey()) + .queryParam("isEtf", true) + .build()) + .retrieve() + .bodyToFlux(FmpStockData.class) + .map(fmpStockData -> { + fmpStockData.setSector(ETF.getName()); + return fmpStockData; + }) + .collectList() + .block() + .stream(); + } + + private List fetchVolumeList(final Exchange exchange) { + return fmpWebClient.get() + .uri(uriBuilder -> uriBuilder + .path(fmpProperties.getExchangeSymbolsStockListPath() + exchange.name()) + .queryParam("apikey", fmpProperties.getApiKey()) + .build()) + .retrieve() + .bodyToFlux(FmpVolumeData.class) + .collectList() + .block(); + } + + @Override + public List getPastDividendList() { + + // 현재 시간을 기준으로 작년 1월 ~ 12월의 배당금 데이터를 조회 + List result = new ArrayList<>(); + for (int month = 12; month >= 3; month -= 3) { + + Instant date = LocalDate.of( + InstantProvider.getLastYear(), + month, + 1) + .atStartOfDay() + .toInstant(UTC); + + List dividendResponses = fetchDividendList(date) + .stream() + .map(FmpDividendData::toDividendData) + .toList(); + + if (dividendResponses.isEmpty()) { + log.error("FmpClient updateDividendData 수행 중 에러 발생: dividendResponses is empty"); + continue; + } + + result.addAll(dividendResponses); + } + + return result; + } + + @Override + public List getUpcomingDividendList() { + + List dividendResponse = fetchDividendList( + LocalDate.now().atStartOfDay().toInstant(UTC), + LocalDate.now().plusMonths(3).atStartOfDay().toInstant(UTC) + ) + .stream() + .map(FmpDividendData::toDividendData) + .toList(); + + if (dividendResponse.isEmpty()) { + log.error("FmpClient updateDividendData 수행 중 에러 발생: dividendResponses is empty"); + } + + return dividendResponse; + } + + private List fetchDividendList(Instant date) { + return fmpWebClient.get() + .uri(uriBuilder -> + uriBuilder + .path(fmpProperties.getStockDividendCalenderPath()) + .queryParam("to", DateFormat.formatInstant(date)) + .queryParam("apikey", fmpProperties.getApiKey()) + .build()) + .retrieve() + .bodyToFlux(FmpDividendData.class) + .onErrorResume(throwable -> { + log.error("FmpClient updateDividendData 수행 중 에러 발생: {}", throwable.getMessage()); + return Mono.empty(); + }) + .collectList() + .block(); + } + + private List fetchDividendList(Instant from, Instant to) { + return fmpWebClient.get() + .uri(uriBuilder -> + uriBuilder + .path(fmpProperties.getStockDividendCalenderPath()) + .queryParam("from", DateFormat.formatInstant(from)) + .queryParam("to", DateFormat.formatInstant(to)) + .queryParam("apikey", fmpProperties.getApiKey()) + .build()) + .retrieve() + .bodyToFlux(FmpDividendData.class) + .onErrorResume(throwable -> { + log.error("FmpClient updateDividendData 수행 중 에러 발생: {}", throwable.getMessage()); + return Mono.empty(); + }) + .collectList() + .block(); + } +} diff --git a/batch/src/main/java/nexters/payout/batch/infra/fmp/FmpProperties.java b/batch/src/main/java/nexters/payout/batch/infra/fmp/FmpProperties.java new file mode 100644 index 00000000..61e62920 --- /dev/null +++ b/batch/src/main/java/nexters/payout/batch/infra/fmp/FmpProperties.java @@ -0,0 +1,17 @@ +package nexters.payout.batch.infra.fmp; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("financial.fmp") +@RequiredArgsConstructor +@Getter +public class FmpProperties { + final String apiKey; + final String baseUrl; + final String stockListPath; + final String stockScreenerPath; + final String exchangeSymbolsStockListPath; + final String stockDividendCalenderPath; +} diff --git a/batch/src/main/java/nexters/payout/batch/infra/ninjas/NinjasDto.java b/batch/src/main/java/nexters/payout/batch/infra/ninjas/NinjasDto.java new file mode 100644 index 00000000..804376b0 --- /dev/null +++ b/batch/src/main/java/nexters/payout/batch/infra/ninjas/NinjasDto.java @@ -0,0 +1,8 @@ +package nexters.payout.batch.infra.ninjas; + +record NinjasStockLogo( + String name, + String ticker, + String image +) { +} \ No newline at end of file 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 new file mode 100644 index 00000000..b4c46b27 --- /dev/null +++ b/batch/src/main/java/nexters/payout/batch/infra/ninjas/NinjasFinancialClient.java @@ -0,0 +1,42 @@ +package nexters.payout.batch.infra.ninjas; + +import lombok.extern.slf4j.Slf4j; +import nexters.payout.batch.application.StockLogo; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Service +@Slf4j +public class NinjasFinancialClient implements StockLogo { + + private final WebClient ninjasWebClient; + private final NinjasProperties ninjasProperties; + + NinjasFinancialClient(final NinjasProperties ninjasProperties) { + this.ninjasProperties = ninjasProperties; + this.ninjasWebClient = WebClient.builder() + .baseUrl(ninjasProperties.getBaseUrl()) + .defaultHeader("X-Api-Key", ninjasProperties.getApiKey()) + .build(); + } + + @Override + public String getLogoUrl(String ticker) { + return fetchLogoUrl(ticker).image(); + } + + private NinjasStockLogo fetchLogoUrl(String ticker) { + return ninjasWebClient.get() + .uri(uriBuilder -> uriBuilder + .path(ninjasProperties.getLogoPath()) + .queryParam("ticker", ticker) + .build()) + .retrieve() + .bodyToFlux(NinjasStockLogo.class) + .next() + .doOnError(e -> log.error("fetchLogoUrl 호출 실패: {}", e.getMessage())) + .onErrorReturn(new NinjasStockLogo(ticker, ticker, null)) + .blockOptional() + .orElse(new NinjasStockLogo(ticker, ticker, null)); + } +} diff --git a/batch/src/main/java/nexters/payout/batch/infra/ninjas/NinjasProperties.java b/batch/src/main/java/nexters/payout/batch/infra/ninjas/NinjasProperties.java new file mode 100644 index 00000000..44fc41e9 --- /dev/null +++ b/batch/src/main/java/nexters/payout/batch/infra/ninjas/NinjasProperties.java @@ -0,0 +1,14 @@ +package nexters.payout.batch.infra.ninjas; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("financial.ninjas") +@RequiredArgsConstructor +@Getter +public class NinjasProperties { + final String apiKey; + final String baseUrl; + final String logoPath; +} diff --git a/batch/src/main/resources/application-dev.yml b/batch/src/main/resources/application-dev.yml new file mode 100644 index 00000000..980b9ec1 --- /dev/null +++ b/batch/src/main/resources/application-dev.yml @@ -0,0 +1,33 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3306/nexters + username: test + password: test + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + show-sql: true + +financial: + fmp: + api-key: ${FMP_API_KEY} + base-url: https://financialmodelingprep.com + stock-list-path: /api/v3/stock/list + exchange-symbols-stock-list-path: /api/v3/symbol/ + stock-screener-path: /api/v3/stock-screener + stock-dividend-calender-path: /api/v3/stock_dividend_calendar + ninjas: + api-key: ${NINJAS_API_KEY} + base-url: https://api.api-ninjas.com + logo-path: /v1/logo + +schedules: + cron: + stock: "0 0 3 * * *" + 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 new file mode 100644 index 00000000..96241081 --- /dev/null +++ b/batch/src/main/resources/application-prod.yml @@ -0,0 +1,43 @@ +spring: + datasource: + url: jdbc:mysql://${DB_HOSTNAME}:${DB_PORT}/${DB_DATABASE} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + show-sql: false + + flyway: + enabled: true + baseline-on-migrate: true + url: jdbc:mysql://${DB_HOSTNAME}:${DB_PORT}/${DB_DATABASE} + user: ${DB_USERNAME} + password: ${DB_PASSWORD} + baseline-version: 0 + +schedules: + cron: + stock: "0 2 * * * *" + dividend: + past: "0 4 * * * 0" + future: "0 4 * * * *" + +financial: + fmp: + api-key: ${FMP_API_KEY} + base-url: https://financialmodelingprep.com + stock-list-path: /api/v3/stock/list + exchange-symbols-stock-list-path: /api/v3/symbol/ + stock-screener-path: /api/v3/stock-screener + stock-dividend-calender-path: /api/v3/stock_dividend_calendar + ninjas: + api-key: ${NINJAS_API_KEY} + base-url: https://api.api-ninjas.com + logo-path: /v1/logo \ No newline at end of file diff --git a/batch/src/main/resources/application-test.yml b/batch/src/main/resources/application-test.yml new file mode 100644 index 00000000..7c3de5e6 --- /dev/null +++ b/batch/src/main/resources/application-test.yml @@ -0,0 +1,22 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:test;MODE=MySQL + username: sa + password: + + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true + show-sql: true + +schedules: + cron: + stock: "-" + dividend: + past: "-" + future: "-" \ No newline at end of file diff --git a/batch/src/main/resources/application.properties b/batch/src/main/resources/application.properties deleted file mode 100644 index 8b137891..00000000 --- a/batch/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/batch/src/main/resources/application.yml b/batch/src/main/resources/application.yml new file mode 100644 index 00000000..76d483dd --- /dev/null +++ b/batch/src/main/resources/application.yml @@ -0,0 +1,5 @@ +spring: + profiles: + active: dev + + diff --git a/batch/src/test/java/nexters/dividend/batch/DividendBatchApplicationTests.java b/batch/src/test/java/nexters/dividend/batch/DividendBatchApplicationTests.java deleted file mode 100644 index 61ed9250..00000000 --- a/batch/src/test/java/nexters/dividend/batch/DividendBatchApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package nexters.dividend.batch; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class DividendBatchApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/batch/src/test/java/nexters/payout/batch/PayoutBatchApplicationTests.java b/batch/src/test/java/nexters/payout/batch/PayoutBatchApplicationTests.java new file mode 100644 index 00000000..4a2fb959 --- /dev/null +++ b/batch/src/test/java/nexters/payout/batch/PayoutBatchApplicationTests.java @@ -0,0 +1,15 @@ +package nexters.payout.batch; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@TestPropertySource(properties = { "spring.config.location=classpath:application-test.yml" }) +class PayoutBatchApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/batch/src/test/java/nexters/payout/batch/application/DividendBatchServiceTest.java b/batch/src/test/java/nexters/payout/batch/application/DividendBatchServiceTest.java new file mode 100644 index 00000000..9568a2ca --- /dev/null +++ b/batch/src/test/java/nexters/payout/batch/application/DividendBatchServiceTest.java @@ -0,0 +1,186 @@ +package nexters.payout.batch.application; + +import nexters.payout.batch.common.AbstractBatchServiceTest; +import nexters.payout.domain.DividendFixture; +import nexters.payout.domain.StockFixture; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.stock.domain.Stock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockitoAnnotations; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.data.auditing.AuditingHandler; +import org.springframework.data.auditing.DateTimeProvider; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static java.time.ZoneOffset.UTC; +import static nexters.payout.core.time.InstantProvider.*; +import static nexters.payout.domain.StockFixture.AAPL; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; + +@DisplayName("배당금 스케쥴러 서비스 테스트") +class DividendBatchServiceTest extends AbstractBatchServiceTest { + + @MockBean + DateTimeProvider dateTimeProvider; + + @SpyBean + AuditingHandler auditingHandler; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + auditingHandler.setDateTimeProvider(dateTimeProvider); + } + + @Test + void 새로운_과거_배당금_정보를_생성한다() { + + // given + Stock stock = stockRepository.save(StockFixture.createStock(AAPL, 12.51, 120000)); + Dividend expected = DividendFixture.createDividend(stock.getId()); + + List responses = new ArrayList<>(); + responses.add(new FinancialClient.DividendData( + Instant.parse("2023-12-21T00:00:00Z"), + "May 31, 23", + 12.21, + "AAPL", + 12.21, + Instant.parse("2023-12-21T00:00:00Z"), + Instant.parse("2023-12-23T00:00:00Z"), + Instant.parse("2023-12-22T00:00:00Z"))); + + given(financialClient.getPastDividendList()).willReturn(responses); + + // when + dividendBatchService.updatePastDividendInfo(); + + // then + assertThat(dividendRepository.findByStockIdAndExDividendDate( + stock.getId(), + Instant.parse("2023-12-21T00:00:00Z"))) + .isPresent(); + + Dividend actual = dividendRepository.findByStockIdAndExDividendDate( + stock.getId(), + Instant.parse("2023-12-21T00:00:00Z")) + .get(); + + assertAll( + () -> assertThat(actual.getDividend()).isEqualTo(expected.getDividend()), + () -> assertThat(actual.getExDividendDate()).isEqualTo(expected.getExDividendDate()), + () -> assertThat(dividendRepository.findAll().size()).isEqualTo(1) + ); + } + + @Test + void 기존의_과거_배당금_정보를_갱신한다() { + + // given + Stock stock = stockRepository.save(StockFixture.createStock(AAPL, 12.51, 120000)); + Dividend expected = dividendRepository.save(DividendFixture.createDividendWithNullDate(stock.getId())); + + List responses = new ArrayList<>(); + responses.add(new FinancialClient.DividendData( + Instant.parse("2023-12-21T00:00:00Z"), + "May 31, 23", + 12.21, + AAPL, + 12.21, + Instant.parse("2023-12-21T00:00:00Z"), + Instant.parse("2023-12-23T00:00:00Z"), + Instant.parse("2023-12-22T00:00:00Z"))); + + given(financialClient.getPastDividendList()).willReturn(responses); + + // when + dividendBatchService.updatePastDividendInfo(); + + // then + Dividend actual = dividendRepository.findByStockIdAndExDividendDate( + stock.getId(), + Instant.parse("2023-12-21T00:00:00Z")) + .get(); + + assertAll( + () -> assertThat(actual.getDividend()).isEqualTo(expected.getDividend()), + () -> assertThat(actual.getExDividendDate()).isEqualTo(expected.getExDividendDate()), + () -> assertThat(actual.getPaymentDate()).isEqualTo(Instant.parse("2023-12-23T00:00:00Z")), + () -> assertThat(actual.getDeclarationDate()).isEqualTo(Instant.parse("2023-12-22T00:00:00Z")), + () -> assertThat(dividendRepository.findAll().size()).isEqualTo(1) + ); + } + + @Test + void 미래_배당금_정보를_생성할때_어제_삽입된_미래_배당금_정보는_제거된다() { + // given + given(dateTimeProvider.getNow()).willReturn(Optional.of(LocalDateTime.now().minusDays(1))); + Stock stock = stockRepository.save(StockFixture.createStock(AAPL, 12.51, 120000)); + dividendRepository.save(DividendFixture.createDividend( + stock.getId(), + 21.02, + LocalDateTime.now().toInstant(UTC))); + + given(financialClient.getUpcomingDividendList()).willReturn(new ArrayList<>()); + + // when + given(dateTimeProvider.getNow()).willReturn(Optional.of(LocalDateTime.now())); + dividendBatchService.updateUpcomingDividendInfo(); + + // then + assertThat(dividendRepository.count()).isEqualTo(0); + } + + @Test + void 새로운_미래_배당금_정보를_생성한다() { + // given + Stock stock = stockRepository.save(StockFixture.createStock(AAPL, 12.51, 120000)); + Dividend expected = DividendFixture.createDividend(stock.getId()); + Instant expectedDate = LocalDateTime.now().plusDays(1).toInstant(UTC); + + List responses = new ArrayList<>(); + responses.add(new FinancialClient.DividendData( + expectedDate, + "May 31, 23", + 12.21, + AAPL, + 12.21, + expectedDate, + expectedDate, + expectedDate)); + + given(financialClient.getUpcomingDividendList()).willReturn(responses); + + // when + dividendBatchService.updateUpcomingDividendInfo(); + + // then + assertThat(dividendRepository.findByStockIdAndExDividendDate( + stock.getId(), + expectedDate)) + .isPresent(); + + Dividend actual = dividendRepository.findByStockIdAndExDividendDate( + stock.getId(), + expectedDate) + .get(); + + assertAll( + () -> assertThat(actual.getDividend()).isEqualTo(expected.getDividend()), + () -> assertThat(getYear(actual.getExDividendDate())).isEqualTo(getYear(expectedDate)), + () -> assertThat(getMonth(actual.getExDividendDate())).isEqualTo(getMonth(expectedDate)), + () -> assertThat(getDayOfMonth(actual.getExDividendDate())).isEqualTo(getDayOfMonth(expectedDate)), + () -> assertThat(dividendRepository.findAll().size()).isEqualTo(1) + ); + } +} \ No newline at end of file diff --git a/batch/src/test/java/nexters/payout/batch/application/LatestStockFixture.java b/batch/src/test/java/nexters/payout/batch/application/LatestStockFixture.java new file mode 100644 index 00000000..9b6e95d8 --- /dev/null +++ b/batch/src/test/java/nexters/payout/batch/application/LatestStockFixture.java @@ -0,0 +1,11 @@ +package nexters.payout.batch.application; + +import nexters.payout.domain.stock.domain.Exchange; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.batch.application.FinancialClient.StockData; + +public class LatestStockFixture { + public static StockData createStockData(String ticker, Double price, Integer volume) { + return new StockData(ticker, ticker, Exchange.AMEX.name(), Sector.FINANCIAL_SERVICES, "industry", price, volume, volume); + } +} diff --git a/batch/src/test/java/nexters/payout/batch/application/StockBatchServiceTest.java b/batch/src/test/java/nexters/payout/batch/application/StockBatchServiceTest.java new file mode 100644 index 00000000..c452af08 --- /dev/null +++ b/batch/src/test/java/nexters/payout/batch/application/StockBatchServiceTest.java @@ -0,0 +1,35 @@ +package nexters.payout.batch.application; + +import nexters.payout.batch.common.AbstractBatchServiceTest; +import nexters.payout.domain.StockFixture; +import nexters.payout.domain.stock.domain.Stock; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; + +@DisplayName("주식 스케쥴러 서비스 테스트") +class StockBatchServiceTest extends AbstractBatchServiceTest { + + @Test + void 현재가와_거래량을_업데이트한다() { + // given + Stock stock = stockRepository.save(StockFixture.createStock(StockFixture.TSLA, 10.0, 1234)); + FinancialClient.StockData stockData = LatestStockFixture.createStockData(stock.getTicker(), 30.0, 4321); + given(financialClient.getLatestStockList()).willReturn(List.of(stockData)); + + // when + stockBatchService.run(); + + // then + Stock actual = stockRepository.findByTicker(stock.getTicker()).get(); + assertAll( + () -> assertThat(actual.getPrice()).isEqualTo(stockData.price()), + () -> assertThat(actual.getVolume()).isEqualTo(stockData.volume()) + ); + } +} \ No newline at end of file diff --git a/batch/src/test/java/nexters/payout/batch/common/AbstractBatchServiceTest.java b/batch/src/test/java/nexters/payout/batch/common/AbstractBatchServiceTest.java new file mode 100644 index 00000000..dde3c178 --- /dev/null +++ b/batch/src/test/java/nexters/payout/batch/common/AbstractBatchServiceTest.java @@ -0,0 +1,37 @@ +package nexters.payout.batch.common; + +import nexters.payout.batch.application.DividendBatchService; +import nexters.payout.batch.application.FinancialClient; +import nexters.payout.batch.application.StockBatchService; +import nexters.payout.domain.dividend.domain.repository.DividendRepository; +import nexters.payout.domain.stock.domain.repository.StockRepository; +import org.junit.jupiter.api.AfterEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public abstract class AbstractBatchServiceTest { + @MockBean + public FinancialClient financialClient; + + @Autowired + public StockRepository stockRepository; + + @Autowired + public DividendRepository dividendRepository; + + @Autowired + public StockBatchService stockBatchService; + + @Autowired + public DividendBatchService dividendBatchService; + + @AfterEach + void afterEach() { + dividendRepository.deleteAll(); + stockRepository.deleteAll(); + } +} diff --git a/build.gradle b/build.gradle index 4f56a3b9..d2399f83 100644 --- a/build.gradle +++ b/build.gradle @@ -11,4 +11,4 @@ allprojects { jar { enabled = false -} \ No newline at end of file +} diff --git a/core/build.gradle b/core/build.gradle index 99a76661..dc480752 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -10,6 +10,8 @@ jar.enabled = true dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework.boot:spring-boot-starter-test' + // Spring Web + implementation 'org.springframework.boot:spring-boot-starter-web' } tasks.named('test') { diff --git a/core/src/main/java/nexters/dividend/core/DividendCoreApplication.java b/core/src/main/java/nexters/payout/core/PayoutCoreApplication.java similarity index 59% rename from core/src/main/java/nexters/dividend/core/DividendCoreApplication.java rename to core/src/main/java/nexters/payout/core/PayoutCoreApplication.java index 7622dfc1..c28a9b61 100644 --- a/core/src/main/java/nexters/dividend/core/DividendCoreApplication.java +++ b/core/src/main/java/nexters/payout/core/PayoutCoreApplication.java @@ -1,13 +1,13 @@ -package nexters.dividend.core; +package nexters.payout.core; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class DividendCoreApplication { +public class PayoutCoreApplication { public static void main(String[] args) { - SpringApplication.run(DividendCoreApplication.class, args); + SpringApplication.run(PayoutCoreApplication.class, args); } } diff --git a/core/src/main/java/nexters/dividend/core/exception/ErrorResponse.java b/core/src/main/java/nexters/payout/core/exception/ErrorResponse.java similarity index 64% rename from core/src/main/java/nexters/dividend/core/exception/ErrorResponse.java rename to core/src/main/java/nexters/payout/core/exception/ErrorResponse.java index d2e1ff00..1381cc3b 100644 --- a/core/src/main/java/nexters/dividend/core/exception/ErrorResponse.java +++ b/core/src/main/java/nexters/payout/core/exception/ErrorResponse.java @@ -1,4 +1,4 @@ -package nexters.dividend.core.exception; +package nexters.payout.core.exception; public record ErrorResponse( int code, diff --git a/core/src/main/java/nexters/dividend/core/exception/GlobalExceptionHandler.java b/core/src/main/java/nexters/payout/core/exception/GlobalExceptionHandler.java similarity index 83% rename from core/src/main/java/nexters/dividend/core/exception/GlobalExceptionHandler.java rename to core/src/main/java/nexters/payout/core/exception/GlobalExceptionHandler.java index adcd078f..df5a59cc 100644 --- a/core/src/main/java/nexters/dividend/core/exception/GlobalExceptionHandler.java +++ b/core/src/main/java/nexters/payout/core/exception/GlobalExceptionHandler.java @@ -1,8 +1,8 @@ -package nexters.dividend.core.exception; +package nexters.payout.core.exception; -import nexters.dividend.core.exception.error.AlreadyExistsException; -import nexters.dividend.core.exception.error.BadRequestException; -import nexters.dividend.core.exception.error.NotFoundException; +import nexters.payout.core.exception.error.AlreadyExistsException; +import nexters.payout.core.exception.error.BadRequestException; +import nexters.payout.core.exception.error.NotFoundException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import java.util.NoSuchElementException; @@ -20,6 +21,17 @@ @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + @Override + protected ResponseEntity handleHandlerMethodValidationException( + HandlerMethodValidationException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(HttpStatus.BAD_REQUEST.value(), ex.getMessage())); + } + @Override protected ResponseEntity handleMethodArgumentNotValid( final MethodArgumentNotValidException ex, diff --git a/core/src/main/java/nexters/dividend/core/exception/error/AlreadyExistsException.java b/core/src/main/java/nexters/payout/core/exception/error/AlreadyExistsException.java similarity index 76% rename from core/src/main/java/nexters/dividend/core/exception/error/AlreadyExistsException.java rename to core/src/main/java/nexters/payout/core/exception/error/AlreadyExistsException.java index 0ef83ef6..ca94db27 100644 --- a/core/src/main/java/nexters/dividend/core/exception/error/AlreadyExistsException.java +++ b/core/src/main/java/nexters/payout/core/exception/error/AlreadyExistsException.java @@ -1,4 +1,4 @@ -package nexters.dividend.core.exception.error; +package nexters.payout.core.exception.error; public class AlreadyExistsException extends BaseException { public AlreadyExistsException(final String message) { diff --git a/core/src/main/java/nexters/dividend/core/exception/error/BadRequestException.java b/core/src/main/java/nexters/payout/core/exception/error/BadRequestException.java similarity index 75% rename from core/src/main/java/nexters/dividend/core/exception/error/BadRequestException.java rename to core/src/main/java/nexters/payout/core/exception/error/BadRequestException.java index 80a1b8e7..6c8b3b30 100644 --- a/core/src/main/java/nexters/dividend/core/exception/error/BadRequestException.java +++ b/core/src/main/java/nexters/payout/core/exception/error/BadRequestException.java @@ -1,4 +1,4 @@ -package nexters.dividend.core.exception.error; +package nexters.payout.core.exception.error; public class BadRequestException extends BaseException { public BadRequestException(final String message) { diff --git a/core/src/main/java/nexters/dividend/core/exception/error/BaseException.java b/core/src/main/java/nexters/payout/core/exception/error/BaseException.java similarity index 84% rename from core/src/main/java/nexters/dividend/core/exception/error/BaseException.java rename to core/src/main/java/nexters/payout/core/exception/error/BaseException.java index 75a43773..d2b0c6d6 100644 --- a/core/src/main/java/nexters/dividend/core/exception/error/BaseException.java +++ b/core/src/main/java/nexters/payout/core/exception/error/BaseException.java @@ -1,4 +1,4 @@ -package nexters.dividend.core.exception.error; +package nexters.payout.core.exception.error; public class BaseException extends RuntimeException { private final String message; diff --git a/core/src/main/java/nexters/dividend/core/exception/error/NotFoundException.java b/core/src/main/java/nexters/payout/core/exception/error/NotFoundException.java similarity index 75% rename from core/src/main/java/nexters/dividend/core/exception/error/NotFoundException.java rename to core/src/main/java/nexters/payout/core/exception/error/NotFoundException.java index 8cdb366d..2d512dbd 100644 --- a/core/src/main/java/nexters/dividend/core/exception/error/NotFoundException.java +++ b/core/src/main/java/nexters/payout/core/exception/error/NotFoundException.java @@ -1,4 +1,4 @@ -package nexters.dividend.core.exception.error; +package nexters.payout.core.exception.error; public class NotFoundException extends BaseException { public NotFoundException(final String message) { diff --git a/core/src/main/java/nexters/payout/core/time/DateFormat.java b/core/src/main/java/nexters/payout/core/time/DateFormat.java new file mode 100644 index 00000000..81a5a916 --- /dev/null +++ b/core/src/main/java/nexters/payout/core/time/DateFormat.java @@ -0,0 +1,25 @@ +package nexters.payout.core.time; + +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.util.Date; + +public class DateFormat { + /** + * "yyyy-MM-dd" 형식의 String을 Instant 타입으로 변환합니다. + */ + public static Instant parseInstant(final String date) { + + if (date == null) return null; + return Instant.parse(date + "T00:00:00Z"); + } + + /** + * Instant를 "yyyy-MM-dd" 형식의 String으로 변환합니다. + */ + public static String formatInstant(Instant instant) { + + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd"); + return formatter.format(Date.from(instant)); + } +} diff --git a/core/src/main/java/nexters/payout/core/time/InstantProvider.java b/core/src/main/java/nexters/payout/core/time/InstantProvider.java new file mode 100644 index 00000000..fcbfb307 --- /dev/null +++ b/core/src/main/java/nexters/payout/core/time/InstantProvider.java @@ -0,0 +1,64 @@ +package nexters.payout.core.time; + +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.time.ZoneOffset.UTC; + +public class InstantProvider { + public static LocalDate toLocalDate(Instant instant) { + return LocalDate.ofInstant(instant, UTC); + } + + public static List generateNext12Months() { + YearMonth startYearMonth = getThisYearMonth(); + YearMonth endYearMonth = getAfterYearMonth(11); + + return Stream.iterate(startYearMonth, date -> date.plusMonths(1)) + .limit(startYearMonth.until(endYearMonth, ChronoUnit.MONTHS) + 1) + .collect(Collectors.toList()); + } + + public static YearMonth getThisYearMonth() { + return YearMonth.of(getNow().getYear(), getNow().getMonth()); + } + + public static YearMonth getAfterYearMonth(int month) { + return YearMonth.of(getNow().plusMonths(month).getYear(), getNow().plusMonths(month).getMonthValue()); + } + + public static Integer getThisYear() { + return getNow().getYear(); + } + + public static Integer getNextYear() { + return getNow().plusYears(1).getYear(); + } + + public static Integer getLastYear() { + return getNow().minusYears(1).getYear(); + } + + public static Instant getYesterday() { + return getNow().minusDays(1).atStartOfDay(ZoneId.of("UTC")).toInstant(); + } + + public static Integer getYear(Instant date) { + return ZonedDateTime.ofInstant(date, UTC).getYear(); + } + + public static Integer getMonth(Instant date) { + return ZonedDateTime.ofInstant(date, UTC).getMonthValue(); + } + + public static Integer getDayOfMonth(Instant date) { + return ZonedDateTime.ofInstant(date, UTC).getDayOfMonth(); + } + + public static LocalDate getNow() { + return LocalDate.ofInstant(Instant.now(), UTC); + } +} diff --git a/core/src/main/resources/logback-spring.xml b/core/src/main/resources/logback-spring.xml index a1923d19..e939157f 100644 --- a/core/src/main/resources/logback-spring.xml +++ b/core/src/main/resources/logback-spring.xml @@ -1,11 +1,10 @@ - - - - - - - + + + + + + @@ -16,10 +15,10 @@ ${LOG_PATH}/%d{yyyy-MM-dd}-info-%i.log 50MB - 30 + 3 - %d{HH:mm:ss.SSS} [%level] [%thread] [%logger{36}] - %msg%n + %d{yyyy-MM-dd HH:mm:ss.SSS} [%level] [%thread] [%logger{36}] - %msg%n diff --git a/core/src/test/java/nexters/dividend/core/DividendCoreApplicationTests.java b/core/src/test/java/nexters/payout/core/PayoutCoreApplicationTests.java similarity index 70% rename from core/src/test/java/nexters/dividend/core/DividendCoreApplicationTests.java rename to core/src/test/java/nexters/payout/core/PayoutCoreApplicationTests.java index 41471b56..525fc157 100644 --- a/core/src/test/java/nexters/dividend/core/DividendCoreApplicationTests.java +++ b/core/src/test/java/nexters/payout/core/PayoutCoreApplicationTests.java @@ -1,10 +1,10 @@ -package nexters.dividend.core; +package nexters.payout.core; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -class DividendCoreApplicationTests { +class PayoutCoreApplicationTests { @Test void contextLoads() { diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 00000000..7a1a9d1d --- /dev/null +++ b/deploy.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +# Docker install +if ! command -v docker &> /dev/null; then + echo "Docker is not installed..." + echo "Docker install start..." + sudo apt-get update -y + sudo apt-get install -y apt-transport-https ca-certificates curl gnupg-agent software-properties-common + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - + sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" + sudo apt-get update -y + sudo apt-get install -y docker-ce docker-ce-cli containerd.io + echo "Docker install complete" +else + echo "Docker is already installed" +fi + +# Docker-compose install +if ! command -v docker-compose &> /dev/null; then + echo "Docker-compose is not installed..." + echo "Docker-compose install start..." + sudo curl -L "https://github.com/docker/compose/releases/download/1.28.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + sudo ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose + echo "Docker-compose install complete!" +else + echo "Docker-compose is already installed" +fi + +RUNNING_CONTAINER=$(docker ps | grep blue) +NGINX_CONF="/home/nginx.conf" +RUNNING_NGINX=$(docker ps | grep nginx) +BATCH_CONTAINER="batch" + + +if [ -z "$RUNNING_CONTAINER" ]; then + TARGET_SERVICE="blue-api" + OTHER_SERVICE="green-api" +else + TARGET_SERVICE="green-api" + OTHER_SERVICE="blue-api" +fi + +echo "$TARGET_SERVICE Deploy..." +docker-compose -f /home/docker-compose.yml up -d $TARGET_SERVICE $BATCH_CONTAINER + +# Wait for the target service to be healthy before proceeding +sleep 10 + +if [ -z "$RUNNING_NGINX" ]; then + echo "Starting Nginx..." + docker-compose -f /home/docker-compose.yml up -d nginx +fi + +# Update the nginx config and reload +sed -it "s/$OTHER_SERVICE/$TARGET_SERVICE/" $NGINX_CONF +docker-compose -f /home/docker-compose.yml restart nginx + +# Stop the other service +docker-compose -f /home/docker-compose.yml stop $OTHER_SERVICE \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..43ffdbd8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,82 @@ +version: '3' + +services: + + nginx: + container_name: nginx + image: nginx:latest + ports: + - "80:80" + volumes: + - /home/nginx.conf:/etc/nginx/nginx.conf + depends_on: + - "db" + restart: always + + blue-api: + container_name: blue-api + depends_on: + - db + image: ${NCP_CONTAINER_REGISTRY_API}/payout-api + expose: + - "8080" + environment: + DB_HOSTNAME: ${DB_HOSTNAME} + DB_PORT: ${DB_PORT} + DB_DATABASE: ${DB_DATABASE} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + FMP_API_KEY: ${FMP_API_KEY} + restart: always + volumes: + - ./logs/api-server:/logs + + green-api: + container_name: green-api + depends_on: + - db + image: ${NCP_CONTAINER_REGISTRY_API}/payout-api + expose: + - "8080" + environment: + DB_HOSTNAME: ${DB_HOSTNAME} + DB_PORT: ${DB_PORT} + DB_DATABASE: ${DB_DATABASE} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + FMP_API_KEY: ${FMP_API_KEY} + restart: always + volumes: + - ./logs/api-server:/logs + + batch: + container_name: batch + depends_on: + - db + image: ${NCP_CONTAINER_REGISTRY_BATCH}/payout-batch + environment: + DB_HOSTNAME: ${DB_HOSTNAME} + DB_PORT: ${DB_PORT} + DB_DATABASE: ${DB_DATABASE} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + FMP_API_KEY: ${FMP_API_KEY} + NINJAS_API_KEY: ${NINJAS_API_KEY} + restart: always + volumes: + - ./logs/batch:/logs + + db: + container_name: db + image: mysql:8.0 + platform: linux/amd64 + volumes: + - ./db/data:/var/lib/mysql + environment: + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} + MYSQL_DATABASE: ${DB_DATABASE} + MYSQL_USER: ${DB_USERNAME} + MYSQL_PASSWORD: ${DB_PASSWORD} + ports: + - ${DB_PORT}:${DB_PORT} diff --git a/domain/build.gradle b/domain/build.gradle index d1cae002..79aaaee1 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -1,7 +1,14 @@ +buildscript { + ext { + queryDslVersion = "5.0.0" + } +} + plugins { id 'java' id 'org.springframework.boot' version '3.2.1' id 'io.spring.dependency-management' version '1.1.4' + id 'java-test-fixtures' } repositories { @@ -18,16 +25,55 @@ bootJar.enabled = false jar.enabled = true dependencies { + implementation(project(":core")) + + // lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + // Spring Data JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // Spring Boot test testImplementation 'org.springframework.boot:spring-boot-starter-test' + // Spring Web + implementation 'org.springframework.boot:spring-boot-starter-web' + + // Mac M1 OS + implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64' + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // MySQL runtimeOnly 'com.mysql:mysql-connector-j' + + // H2 Database runtimeOnly 'com.h2database:h2' + + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' } tasks.named('test') { useJUnitPlatform() } + +// QueryDSL +def generated = 'src/main/generated' + +tasks.withType(JavaCompile) { + options.getGeneratedSourceOutputDirectory().set(file(generated)) +} + +sourceSets { + main.java.srcDirs += [ generated ] +} + +clean { + delete file(generated) +} \ No newline at end of file diff --git a/domain/src/main/generated/nexters/payout/domain/QBaseEntity.java b/domain/src/main/generated/nexters/payout/domain/QBaseEntity.java new file mode 100644 index 00000000..420f56d4 --- /dev/null +++ b/domain/src/main/generated/nexters/payout/domain/QBaseEntity.java @@ -0,0 +1,39 @@ +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 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 new file mode 100644 index 00000000..2d5f4b21 --- /dev/null +++ b/domain/src/main/generated/nexters/payout/domain/dividend/domain/QDividend.java @@ -0,0 +1,55 @@ +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); + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + //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/dividend/domain/BaseEntity.java b/domain/src/main/java/nexters/dividend/domain/BaseEntity.java deleted file mode 100644 index 69747dc6..00000000 --- a/domain/src/main/java/nexters/dividend/domain/BaseEntity.java +++ /dev/null @@ -1,54 +0,0 @@ -package nexters.dividend.domain; - -import jakarta.persistence.*; -import lombok.Getter; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -import java.time.Instant; -import java.util.Objects; -import java.util.UUID; - -/** - * 생성일자, 마지막으로 수정된 일자 등 엔티티 별 공통으로 사용되는 클래스입니다. - * - * @author Min Ho CHO - */ -@MappedSuperclass -@EntityListeners(AuditingEntityListener.class) -@Getter -public class BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.UUID) - @Column(unique = true, nullable = false, updatable = false) - private UUID id; - - @Column(name = "created_at", updatable = false) - @CreatedDate - private Instant createdAt; - - @Column(name = "last_modified_at") - @LastModifiedDate - private Instant lastModifiedAt; - - /** - * 엔티티 클래스의 hash code 함수를 재정의한 메서드입니다. - * @return object의 id 기반으로 생성된 hash code - */ - @Override - public int hashCode() { - return Objects.hash(id); - } - - /** - * 엔티티 클래스의 equals 함수를 재정의한 메서드입니다. - * @param obj 비교할 object - * @return 해당 object가 BaseEntity 타입이면서, 같은 id를 가지고 있는지 여부 - */ - @Override - public boolean equals(Object obj) { - return obj instanceof BaseEntity && this.id.equals(((BaseEntity) obj).getId()); - } -} diff --git a/domain/src/main/java/nexters/dividend/domain/dividend/Dividend.java b/domain/src/main/java/nexters/dividend/domain/dividend/Dividend.java deleted file mode 100644 index 6fd57d64..00000000 --- a/domain/src/main/java/nexters/dividend/domain/dividend/Dividend.java +++ /dev/null @@ -1,59 +0,0 @@ -package nexters.dividend.domain.dividend; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import nexters.dividend.domain.BaseEntity; - -import java.time.Instant; -import java.util.UUID; - -/** - * 배당금을 표현하는 클래스입니다. - * - * @author Min Ho CHO - */ -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Dividend extends BaseEntity { - - @Column(nullable = false, updatable = false) - private UUID stockId; - - @Column(nullable = false) - private Integer dividend; - - @Column(nullable = false, updatable = false) - private Instant exDividendDate; - - @Column(nullable = false) - private Instant paymentDate; - - @Column(nullable = false) - private Instant declarationDate; - - private Dividend( - UUID stockId, - Integer dividend, - Instant exDividendDate, - Instant paymentDate, - Instant declarationDate) { - this.stockId = stockId; - this.dividend = dividend; - this.exDividendDate = exDividendDate; - this.paymentDate = paymentDate; - this.declarationDate = declarationDate; - } - - public static Dividend createDividend( - UUID stockId, - Integer dividend, - Instant exDividendDate, - Instant paymentDate, - Instant declarationDate) { - return new Dividend(stockId, dividend, exDividendDate, paymentDate, declarationDate); - } -} diff --git a/domain/src/main/java/nexters/dividend/domain/dividend/repository/DividendRepository.java b/domain/src/main/java/nexters/dividend/domain/dividend/repository/DividendRepository.java deleted file mode 100644 index 703d5821..00000000 --- a/domain/src/main/java/nexters/dividend/domain/dividend/repository/DividendRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package nexters.dividend.domain.dividend.repository; - -import nexters.dividend.domain.dividend.Dividend; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.UUID; - -/** - * 배당금 JPA repository 클래스입니다. - * - * @author Min Ho CHO - */ -public interface DividendRepository extends JpaRepository { - -} diff --git a/domain/src/main/java/nexters/dividend/domain/stock/Sector.java b/domain/src/main/java/nexters/dividend/domain/stock/Sector.java deleted file mode 100644 index a074b434..00000000 --- a/domain/src/main/java/nexters/dividend/domain/stock/Sector.java +++ /dev/null @@ -1,22 +0,0 @@ -package nexters.dividend.domain.stock; - -public enum Sector { - TECHNOLOGY("Technology"), - COMMUNICATION_SERVICES("Communication Services"), - HEALTHCARE("Healthcare"), - CONSUMER_CYCLICAL("Consumer Cyclical"), - CONSUMER_DEFENSIVE("Consumer Defensive"), - BASIC_MATERIALS("Basic Materials"), - FINANCIAL_SERVICES("Financial Services"), - INDUSTRIALS("Industrials"), - REAL_ESTATE("Real Estate"), - ENERGY("Energy"), - UTILITIES("Utilities"), - ETC("ETC"); - - private final String value; - - Sector(final String value) { - this.value = value; - } -} diff --git a/domain/src/main/java/nexters/dividend/domain/stock/Stock.java b/domain/src/main/java/nexters/dividend/domain/stock/Stock.java deleted file mode 100644 index 8816e847..00000000 --- a/domain/src/main/java/nexters/dividend/domain/stock/Stock.java +++ /dev/null @@ -1,37 +0,0 @@ -package nexters.dividend.domain.stock; - -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import nexters.dividend.domain.BaseEntity; - -import java.util.ArrayList; -import java.util.List; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -class Stock extends BaseEntity { - - @Column(unique = true, nullable = false, length = 10) - private String ticker; - - @Column(nullable = false) - private String name; - - @Enumerated(EnumType.STRING) - private Sector sector; - - @ElementCollection - private List dividendCycle = new ArrayList<>(); - - @Column(length = 10) - private String exchange; - - private String industry; - - private Double price; - - private Integer volume; -} diff --git a/domain/src/main/java/nexters/dividend/domain/stock/StockRepository.java b/domain/src/main/java/nexters/dividend/domain/stock/StockRepository.java deleted file mode 100644 index 0d854ff4..00000000 --- a/domain/src/main/java/nexters/dividend/domain/stock/StockRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package nexters.dividend.domain.stock; - -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.UUID; - -public interface StockRepository extends JpaRepository { - -} diff --git a/domain/src/main/java/nexters/payout/domain/BaseEntity.java b/domain/src/main/java/nexters/payout/domain/BaseEntity.java new file mode 100644 index 00000000..22ca1dc5 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/BaseEntity.java @@ -0,0 +1,26 @@ +package nexters.payout.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public class BaseEntity { + + @Column(name = "created_at", updatable = false) + @CreatedDate + private Instant createdAt; + + @Column(name = "last_modified_at") + @LastModifiedDate + private Instant lastModifiedAt; + +} diff --git a/domain/src/main/java/nexters/dividend/domain/DomainApplication.java b/domain/src/main/java/nexters/payout/domain/DomainApplication.java similarity index 89% rename from domain/src/main/java/nexters/dividend/domain/DomainApplication.java rename to domain/src/main/java/nexters/payout/domain/DomainApplication.java index c3545a19..46a76ccd 100644 --- a/domain/src/main/java/nexters/dividend/domain/DomainApplication.java +++ b/domain/src/main/java/nexters/payout/domain/DomainApplication.java @@ -1,4 +1,4 @@ -package nexters.dividend.domain; +package nexters.payout.domain; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/domain/src/main/java/nexters/payout/domain/common/config/DomainService.java b/domain/src/main/java/nexters/payout/domain/common/config/DomainService.java new file mode 100644 index 00000000..ab9d6141 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/common/config/DomainService.java @@ -0,0 +1,12 @@ +package nexters.payout.domain.common.config; + +import org.springframework.stereotype.Component; + +import java.lang.annotation.*; + +@Component +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface DomainService { +} \ No newline at end of file diff --git a/domain/src/main/java/nexters/payout/domain/common/config/JpaConfig.java b/domain/src/main/java/nexters/payout/domain/common/config/JpaConfig.java new file mode 100644 index 00000000..e136d6a8 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/common/config/JpaConfig.java @@ -0,0 +1,9 @@ +package nexters.payout.domain.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/domain/src/main/java/nexters/payout/domain/common/config/QueryDslConfig.java b/domain/src/main/java/nexters/payout/domain/common/config/QueryDslConfig.java new file mode 100644 index 00000000..3c8564c9 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/common/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package nexters.payout.domain.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } +} diff --git a/domain/src/main/java/nexters/payout/domain/dividend/application/DividendCommandService.java b/domain/src/main/java/nexters/payout/domain/dividend/application/DividendCommandService.java new file mode 100644 index 00000000..48b588e9 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/dividend/application/DividendCommandService.java @@ -0,0 +1,34 @@ +package nexters.payout.domain.dividend.application; + +import lombok.RequiredArgsConstructor; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.dividend.domain.repository.DividendRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional +public class DividendCommandService { + + private final DividendRepository dividendRepository; + + public void saveOrUpdate(UUID stockId, Dividend dividendData) { + dividendRepository.findByStockIdAndExDividendDate(stockId, dividendData.getExDividendDate()) + .ifPresentOrElse( + existing -> existing.update( + dividendData.getDividend(), + dividendData.getPaymentDate(), + dividendData.getDeclarationDate() + ), + () -> dividendRepository.save(dividendData) + ); + } + + public void deleteInvalidDividend() { + dividendRepository.deleteByYearAndCreatedAt(InstantProvider.getThisYear(), InstantProvider.getYesterday()); + } +} diff --git a/domain/src/main/java/nexters/payout/domain/dividend/application/dto/UpdateDividendRequest.java b/domain/src/main/java/nexters/payout/domain/dividend/application/dto/UpdateDividendRequest.java new file mode 100644 index 00000000..36235eb1 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/dividend/application/dto/UpdateDividendRequest.java @@ -0,0 +1,10 @@ +package nexters.payout.domain.dividend.application.dto; + +import java.time.Instant; + +public record UpdateDividendRequest( + Double dividend, + Instant paymentDate, + Instant declarationDate +) { +} diff --git a/domain/src/main/java/nexters/payout/domain/dividend/domain/Dividend.java b/domain/src/main/java/nexters/payout/domain/dividend/domain/Dividend.java new file mode 100644 index 00000000..15561333 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/dividend/domain/Dividend.java @@ -0,0 +1,81 @@ +package nexters.payout.domain.dividend.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import nexters.payout.domain.BaseEntity; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Dividend extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, updatable = false) + private UUID stockId; + + private Double dividend; + + @Column(updatable = false) + private Instant exDividendDate; + + private Instant paymentDate; + + private Instant declarationDate; + + public Dividend(final UUID id, final UUID stockId, final Double dividend, final Instant exDividendDate, + final Instant paymentDate, final Instant declarationDate) { + this.id = id; + this.stockId = stockId; + this.dividend = dividend; + this.exDividendDate = exDividendDate; + this.paymentDate = paymentDate; + this.declarationDate = declarationDate; + } + + public Dividend(final UUID stockId, final Double dividend, final Instant exDividendDate, + final Instant paymentDate, final Instant declarationDate) { + this(null, stockId, dividend, exDividendDate, paymentDate, declarationDate); + } + + public void update(final Double dividend, final Instant paymentDate, final Instant declarationDate) { + this.dividend = dividend; + this.paymentDate = paymentDate; + this.declarationDate = declarationDate; + } + + public static Dividend create( + final UUID stockId, final Double dividend, final Instant exDividendDate, + final Instant paymentDate, final Instant declarationDate) { + return new Dividend(stockId, dividend, exDividendDate, paymentDate, declarationDate); + } + + @Override + public boolean equals(Object obj) { + return obj instanceof Dividend && this.id.equals(((Dividend) obj).getId()); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "Dividend{" + + "stockId=" + stockId + + ", dividend=" + dividend + + ", exDividendDate=" + exDividendDate + + ", paymentDate=" + paymentDate + + ", declarationDate=" + declarationDate + + '}'; + } +} diff --git a/domain/src/main/java/nexters/payout/domain/dividend/domain/repository/DividendRepository.java b/domain/src/main/java/nexters/payout/domain/dividend/domain/repository/DividendRepository.java new file mode 100644 index 00000000..3412fb1a --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/dividend/domain/repository/DividendRepository.java @@ -0,0 +1,15 @@ +package nexters.payout.domain.dividend.domain.repository; + +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.dividend.infra.DividendRepositoryCustom; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface DividendRepository extends JpaRepository, DividendRepositoryCustom { + + List findAllByStockId(UUID stockId); + + List findAllByStockIdIn(List stockIds); +} 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 new file mode 100644 index 00000000..dbd020d6 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryCustom.java @@ -0,0 +1,17 @@ +package nexters.payout.domain.dividend.infra; + + +import nexters.payout.domain.dividend.domain.Dividend; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface DividendRepositoryCustom { + + Optional findByStockIdAndExDividendDate(UUID stockId, Instant date); + List findAllByTickerAndYearAndMonth(String ticker, Integer year, Integer month); + List findAllByTickerAndYear(String ticker, 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 new file mode 100644 index 00000000..cbf7b9e7 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryImpl.java @@ -0,0 +1,76 @@ +package nexters.payout.domain.dividend.infra; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.dividend.domain.Dividend; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static nexters.payout.domain.dividend.domain.QDividend.dividend1; +import static nexters.payout.domain.stock.domain.QStock.stock; + +@Repository +public class DividendRepositoryImpl implements DividendRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + public DividendRepositoryImpl(EntityManager em) { + this.queryFactory = new JPAQueryFactory(em); + } + + @Override + public Optional findByStockIdAndExDividendDate(UUID stockId, Instant date) { + + return Optional.ofNullable( + queryFactory + .selectFrom(dividend1) + .innerJoin(stock).on(dividend1.stockId.eq(stock.id)) + .where(stock.id.eq(stockId) + .and(dividend1.exDividendDate.year().eq(InstantProvider.getYear(date))) + .and(dividend1.exDividendDate.month().eq(InstantProvider.getMonth(date))) + .and(dividend1.exDividendDate.dayOfMonth().eq(InstantProvider.getDayOfMonth(date)))) + .fetchOne() + ); + } + + @Override + public List findAllByTickerAndYearAndMonth(String ticker, 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.ticker.eq(ticker))) + .fetch(); + } + + @Override + public List findAllByTickerAndYear(String ticker, Integer year) { + + return queryFactory + .selectFrom(dividend1) + .innerJoin(stock).on(dividend1.stockId.eq(stock.id)) + .where(dividend1.exDividendDate.year().eq(year) + .and(stock.ticker.eq(ticker))) + .fetch(); + } + + @Override + public void deleteByYearAndCreatedAt(Integer year, Instant createdAt) { + + queryFactory + .delete(dividend1) + .where(dividend1.exDividendDate.year().eq(year) + .and(dividend1.createdAt.year().eq(InstantProvider.getYear(createdAt))) + .and(dividend1.createdAt.month().eq(InstantProvider.getMonth(createdAt))) + .and(dividend1.createdAt.dayOfMonth().eq(InstantProvider.getDayOfMonth(createdAt)))) + .execute(); + } +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/application/StockCommandService.java b/domain/src/main/java/nexters/payout/domain/stock/application/StockCommandService.java new file mode 100644 index 00000000..ea8b71cd --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/application/StockCommandService.java @@ -0,0 +1,34 @@ +package nexters.payout.domain.stock.application; + +import lombok.RequiredArgsConstructor; +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.domain.repository.StockRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class StockCommandService { + + private final StockRepository stockRepository; + + public void create(Stock stockData) { + stockRepository.save(stockData); + } + + public void update(String ticker, Stock stockData) { + stockRepository.findByTicker(ticker) + .ifPresent( + existing -> existing.update(stockData.getPrice(), stockData.getVolume(), stockData.getSector()) + ); + } + + public void saveOrUpdate(String ticker, Stock stockData) { + stockRepository.findByTicker(ticker) + .ifPresentOrElse( + existing -> existing.update(stockData.getPrice(), stockData.getVolume(), stockData.getSector()), + () -> stockRepository.save(stockData) + ); + } +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/application/dto/UpdateStockRequest.java b/domain/src/main/java/nexters/payout/domain/stock/application/dto/UpdateStockRequest.java new file mode 100644 index 00000000..4868f9c7 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/application/dto/UpdateStockRequest.java @@ -0,0 +1,7 @@ +package nexters.payout.domain.stock.application.dto; + +public record UpdateStockRequest( + Double price, + Integer volume +) { +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/domain/Exchange.java b/domain/src/main/java/nexters/payout/domain/stock/domain/Exchange.java new file mode 100644 index 00000000..b373d7d7 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/domain/Exchange.java @@ -0,0 +1,17 @@ +package nexters.payout.domain.stock.domain; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +public enum Exchange { + NASDAQ, + NYSE, + AMEX; + + public static List getNames() { + return Arrays.stream(Exchange.values()) + .map(Enum::name) + .collect(Collectors.toList()); + } +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/domain/Sector.java b/domain/src/main/java/nexters/payout/domain/stock/domain/Sector.java new file mode 100644 index 00000000..2fc5cc76 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/domain/Sector.java @@ -0,0 +1,80 @@ +package nexters.payout.domain.stock.domain; + +import lombok.Getter; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Getter +public enum Sector { + TECHNOLOGY("Technology"), + COMMUNICATION_SERVICES("Communication Services"), + HEALTHCARE("Healthcare"), + CONSUMER_CYCLICAL("Consumer Cyclical"), + CONSUMER_DEFENSIVE("Consumer Defensive"), + BASIC_MATERIALS("Basic Materials"), + FINANCIAL_SERVICES("Financial Services"), + INDUSTRIALS("Industrials"), + REAL_ESTATE("Real Estate"), + ENERGY("Energy"), + UTILITIES("Utilities"), + INDUSTRIAL_GOODS("Industrial Goods"), + FINANCIAL("Financial"), + SERVICES("Services"), + CONGLOMERATES("Conglomerates"), + ETF("ETF"), + ETC("ETC"); + + private final String name; + + Sector(final String name) { + this.name = name; + } + + private static final Map NAME_TO_SECTOR_MAP = Arrays + .stream(values()) + .collect(Collectors.toMap(sector -> sector.name, Function.identity())); + + private static final Set ETC_NAMES = Set.of( + INDUSTRIAL_GOODS.name, FINANCIAL.name, SERVICES.name, CONGLOMERATES.name, ETC.name() + ); + + private static final Set ETC_VALUES = Set.of( + INDUSTRIAL_GOODS.name(), FINANCIAL.name(), SERVICES.name(), CONGLOMERATES.name(), ETC.name() + ); + + public static List getNames() { + return Arrays.stream(Sector.values()) + .map(it -> it.name) + .filter(name -> !name.isEmpty()) + .toList(); + } + + public static Sector fromName(String sectorName) { + if (sectorName == null || isEtcCategoryName(sectorName)) { + return ETC; + } + + return NAME_TO_SECTOR_MAP.getOrDefault(sectorName, ETC); + } + + public static Sector fromValue(String sectorValue) { + if (sectorValue == null || isEtcCategoryValue(sectorValue)) { + return ETC; + } + + return Sector.valueOf(sectorValue); + } + + private static boolean isEtcCategoryName(String value) { + return ETC_NAMES.contains(value); + } + + private static boolean isEtcCategoryValue(String value) { + return ETC_VALUES.contains(value); + } +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/domain/Stock.java b/domain/src/main/java/nexters/payout/domain/stock/domain/Stock.java new file mode 100644 index 00000000..634de7df --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/domain/Stock.java @@ -0,0 +1,98 @@ +package nexters.payout.domain.stock.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import nexters.payout.domain.BaseEntity; + +import java.util.Objects; +import java.util.UUID; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Stock extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(unique = true, nullable = false, length = 50) + private String ticker; + + private String name; + + @Enumerated(EnumType.STRING) + private Sector sector; + + @Column(length = 10) + private String exchange; + + private String industry; + + private Double price; + + private Integer volume; + + private String logoUrl; + + public Stock(final UUID id, final String ticker, final String name, + final Sector sector, final String exchange, final String industry, + final Double price, final Integer volume, final String logoUrl) { + validateTicker(ticker); + this.id = id; + this.ticker = ticker; + this.name = name; + this.sector = sector; + this.exchange = exchange; + this.industry = industry; + this.price = price; + this.volume = volume; + this.logoUrl = logoUrl; + } + + public Stock(final String ticker, final String name, + final Sector sector, final String exchange, final String industry, + final Double price, final Integer volume, final String logoUrl) { + this(null, ticker, name, sector, exchange, industry, price, volume, logoUrl); + } + + private void validateTicker(final String ticker) { + if (ticker.isBlank()) { + throw new IllegalArgumentException("ticker must not be null or empty"); + } + } + + public void update( + final Double price, + final Integer volume, + final Sector sector) { + this.price = price; + this.volume = volume; + this.sector = sector; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof Stock && this.id.equals(((Stock) obj).getId()); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "Stock{" + + "ticker='" + ticker + '\'' + + ", name='" + name + '\'' + + ", sector=" + sector + + ", exchange='" + exchange + '\'' + + ", industry='" + industry + '\'' + + ", price=" + price + + ", volume=" + volume + + '}'; + } +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/domain/repository/StockRepository.java b/domain/src/main/java/nexters/payout/domain/stock/domain/repository/StockRepository.java new file mode 100644 index 00000000..a0671465 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/domain/repository/StockRepository.java @@ -0,0 +1,15 @@ +package nexters.payout.domain.stock.domain.repository; + +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.infra.StockRepositoryCustom; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface StockRepository extends JpaRepository, StockRepositoryCustom { + Optional findByTicker(String ticker); + + List findAllByTickerIn(List tickers); +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/domain/repository/dto/StockDividendDto.java b/domain/src/main/java/nexters/payout/domain/stock/domain/repository/dto/StockDividendDto.java new file mode 100644 index 00000000..a4afb1f9 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/domain/repository/dto/StockDividendDto.java @@ -0,0 +1,10 @@ +package nexters.payout.domain.stock.domain.repository.dto; + +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.stock.domain.Stock; + +public record StockDividendDto( + Stock stock, + Dividend dividend +) { +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/domain/service/SectorAnalysisService.java b/domain/src/main/java/nexters/payout/domain/stock/domain/service/SectorAnalysisService.java new file mode 100644 index 00000000..2470f356 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/domain/service/SectorAnalysisService.java @@ -0,0 +1,85 @@ +package nexters.payout.domain.stock.domain.service; + +import nexters.payout.domain.common.config.DomainService; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@DomainService +public class SectorAnalysisService { + + public Map calculateSectorRatios(final List stockShares) { + Map sectorCountMap = getSectorCountMap(stockShares); + Map> sectorStockMap = getSectorStockMap(stockShares); + double totalValue = totalValue(stockShares); + + Map sectorInfoMap = new HashMap<>(); + + for (Sector sector : Sector.values()) { + if (stockCountBySector(sectorCountMap, sector) > 0) { + Double sectorRatio = totalValueBySector(stockShares, sector) / totalValue; + sectorInfoMap.put(sector, new SectorInfo(sectorRatio, getStocks(sectorStockMap, sector))); + } + } + + return sectorInfoMap; + } + + private Map getSectorCountMap(final List stockShares) { + return stockShares + .stream() + .map(stockShare -> stockShare.stock().getSector()) + .collect(Collectors.groupingBy(Function.identity(), + Collectors.collectingAndThen(Collectors.counting(), Long::intValue))); + } + + private Map> getSectorStockMap(final List stockShares) { + return stockShares + .stream() + .collect(Collectors.groupingBy(stockShare -> stockShare.stock().getSector())); + } + + private static double totalValue(final List stockShares) { + return stockShares + .stream() + .mapToDouble(stockShare -> stockShare.share() * stockShare.stock().getPrice()) + .sum(); + } + + private List getStocks(final Map> sectorStockMap, final Sector sector) { + return sectorStockMap.getOrDefault(sector, Collections.emptyList()); + } + + private Integer stockCountBySector(final Map sectorCountMap, final Sector sector) { + return sectorCountMap.getOrDefault(sector, 0); + } + + private double totalValueBySector(final List stockShares, final Sector sector) { + return stockShares + .stream() + .filter(share -> share.stock().getSector().equals(sector)) + .mapToDouble(stockShare -> stockShare.share() * stockShare.stock().getPrice()) + .sum(); + } + + public record SectorInfo( + Double ratio, + List stockShares + ) { + + } + + public record StockShare( + Stock stock, + Integer share + ) { + + } +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/domain/service/StockDividendAnalysisService.java b/domain/src/main/java/nexters/payout/domain/stock/domain/service/StockDividendAnalysisService.java new file mode 100644 index 00000000..611dabdf --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/domain/service/StockDividendAnalysisService.java @@ -0,0 +1,89 @@ +package nexters.payout.domain.stock.domain.service; + +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.common.config.DomainService; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.stock.domain.Stock; + +import java.time.LocalDate; +import java.time.Month; +import java.util.*; +import java.util.stream.Collectors; + +@DomainService +public class StockDividendAnalysisService { + /** + * 작년 데이터를 기반으로 배당을 주었던 월 리스트를 계산합니다. + */ + public List calculateDividendMonths(final Stock stock, final List dividends) { + int lastYear = InstantProvider.getLastYear(); + + return dividends + .stream() + .filter(dividend -> stock.getId().equals(dividend.getStockId())) + .map(dividend -> InstantProvider.toLocalDate(dividend.getExDividendDate())) + .filter(exDividendDate -> exDividendDate.getYear() == lastYear) + .map(LocalDate::getMonth) + .distinct() + .collect(Collectors.toList()); + } + + /** + * 배당 수익률은 (연간 배당금 / 현재가) 를 기준으로 합니다. + */ + public Double calculateDividendYield(final Stock stock, final List dividends) { + double sumOfDividend = dividends + .stream() + .mapToDouble(Dividend::getDividend) + .sum(); + + Double stockPrice = stock.getPrice(); + + if (stockPrice == null || stockPrice == 0) { + return 0.0; + } + + return sumOfDividend / stockPrice; + } + + /** + * 공시된 현재 연도의 데이터가 있는 경우 실제 지급일을 반환하고, 없으면 작년 데이터를 기반으로 가장 빠른 배당 지급일을 계산합니다. + * 월과 일만 확인하기 때문에 과거 연도가 반환될 수 있습니다. + */ + public Optional findUpcomingDividend( + final List lastYearDividends, final List thisYearDividends + ) { + LocalDate now = InstantProvider.getNow(); + + for (Dividend dividend : thisYearDividends) { + LocalDate exDividendDate = InstantProvider.toLocalDate(dividend.getExDividendDate()); + if (exDividendDate.getYear() == now.getYear() && (isCurrentOrFutureDate(exDividendDate))) { + return Optional.of(dividend); + } + } + + return lastYearDividends + .stream() + .map(dividend -> { + LocalDate exDividendDate = InstantProvider.toLocalDate(dividend.getExDividendDate()); + LocalDate adjustedExDividendDate = exDividendDate.withYear(now.getYear()); + return new AbstractMap.SimpleEntry<>(dividend, adjustedExDividendDate); + }) + .filter(date -> isCurrentOrFutureDate(date.getValue())) + .min(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey); + } + + private boolean isCurrentOrFutureDate(final LocalDate date) { + LocalDate now = InstantProvider.getNow(); + return date.isEqual(now) || date.isAfter(InstantProvider.getNow()); + } + + public Double calculateAverageDividend(final List dividends) { + return dividends + .stream() + .mapToDouble(Dividend::getDividend) + .average() + .orElse(0.0); + } +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/infra/StockRepositoryCustom.java b/domain/src/main/java/nexters/payout/domain/stock/infra/StockRepositoryCustom.java new file mode 100644 index 00000000..ee12f309 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/infra/StockRepositoryCustom.java @@ -0,0 +1,15 @@ +package nexters.payout.domain.stock.infra; + +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.infra.dto.StockDividendDto; +import nexters.payout.domain.stock.infra.dto.StockDividendYieldDto; + +import java.util.List; + +public interface StockRepositoryCustom { + + List findStocksByTickerOrNameWithPriority(String search, Integer pageNumber, Integer pageSize); + List findUpcomingDividendStock(Sector sector, int pageNumber, int pageSize); + List findBiggestDividendYieldStock(int lastYear, Sector sector, int pageNumber, int pageSize); +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/infra/StockRepositoryImpl.java b/domain/src/main/java/nexters/payout/domain/stock/infra/StockRepositoryImpl.java new file mode 100644 index 00000000..1d02e905 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/infra/StockRepositoryImpl.java @@ -0,0 +1,90 @@ +package nexters.payout.domain.stock.infra; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import nexters.payout.domain.stock.domain.QStock; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.infra.dto.StockDividendDto; +import nexters.payout.domain.stock.infra.dto.StockDividendYieldDto; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +import static java.time.ZoneOffset.UTC; +import static nexters.payout.domain.dividend.domain.QDividend.dividend1; +import static nexters.payout.domain.stock.domain.QStock.stock; + +@Repository +@RequiredArgsConstructor +public class StockRepositoryImpl implements StockRepositoryCustom { + + private final Double MAX_DIVIDEND_YIELD = 0.9; + private final JPAQueryFactory queryFactory; + + @Override + public List findStocksByTickerOrNameWithPriority(String keyword, Integer pageNumber, Integer pageSize) { + QStock stock = QStock.stock; + + // 검색 조건 + BooleanExpression tickerStartsWith = stock.ticker.startsWith(keyword); + BooleanExpression nameContains = stock.name.contains(keyword); + + // 정렬 조건 + OrderSpecifier orderByPriority = new CaseBuilder() + .when(tickerStartsWith).then(1) + .when(nameContains).then(2) + .otherwise(3) + .asc(); + OrderSpecifier orderByTicker = stock.ticker.asc(); + OrderSpecifier orderByName = stock.name.asc(); + + long offset = (long) (pageNumber - 1) * pageSize; + + return queryFactory.selectFrom(stock) + .where(tickerStartsWith.or(nameContains)) + .orderBy(orderByPriority, orderByTicker, orderByName) + .offset(offset) + .limit(pageSize) + .fetch(); + } + + @Override + public List findUpcomingDividendStock(Sector sector, int pageNumber, int pageSize) { + + return queryFactory + .select(Projections.constructor(StockDividendDto.class, stock, dividend1)) + .from(stock) + .innerJoin(dividend1).on(stock.id.eq(dividend1.stockId)) + .where(dividend1.exDividendDate.after(LocalDateTime.now().toInstant(UTC)).and(stock.sector.eq(sector))) + .orderBy(dividend1.exDividendDate.asc()) + .offset((long) (pageNumber - 1) * pageSize) + .limit(pageSize) + .fetch(); + } + + @Override + public List findBiggestDividendYieldStock(int lastYear, Sector sector, int pageNumber, int pageSize) { + + NumberExpression dividendYield = dividend1.dividend.sum().coalesce(1.0).divide(stock.price); + + return queryFactory + .select(Projections.constructor(StockDividendYieldDto.class, stock, dividendYield)) + .from(stock) + .innerJoin(dividend1) + .on(stock.id.eq(dividend1.stockId)) + .where(dividend1.exDividendDate.year().eq(lastYear).and(stock.sector.eq(sector))) + .groupBy(stock.id, stock.price) + .orderBy(dividendYield.desc()) + .having(dividendYield.lt(MAX_DIVIDEND_YIELD)) + .offset((long) (pageNumber - 1) * pageSize) + .limit(pageSize) + .fetch(); + } +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/infra/dto/StockDividendDto.java b/domain/src/main/java/nexters/payout/domain/stock/infra/dto/StockDividendDto.java new file mode 100644 index 00000000..2dacfc28 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/infra/dto/StockDividendDto.java @@ -0,0 +1,10 @@ +package nexters.payout.domain.stock.infra.dto; + +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.stock.domain.Stock; + +public record StockDividendDto( + Stock stock, + Dividend dividend +) { +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/infra/dto/StockDividendYieldDto.java b/domain/src/main/java/nexters/payout/domain/stock/infra/dto/StockDividendYieldDto.java new file mode 100644 index 00000000..a04a3cda --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/infra/dto/StockDividendYieldDto.java @@ -0,0 +1,9 @@ +package nexters.payout.domain.stock.infra.dto; + +import nexters.payout.domain.stock.domain.Stock; + +public record StockDividendYieldDto( + Stock stock, + Double dividendYield +) { +} diff --git a/domain/src/main/resources/application-dev.yml b/domain/src/main/resources/application-dev.yml index 04b7b25c..a2d2e0ce 100644 --- a/domain/src/main/resources/application-dev.yml +++ b/domain/src/main/resources/application-dev.yml @@ -7,8 +7,16 @@ spring: jpa: database-platform: org.hibernate.dialect.MySQLDialect hibernate: - ddl-auto: create-drop + ddl-auto: validate properties: hibernate: format_sql: true show-sql: true + + flyway: + enabled: true + baseline-on-migrate: true + url: jdbc:mysql://localhost:3306/nexters + user: test + password: test + baseline-version: 0 diff --git a/domain/src/main/resources/application-prod.yml b/domain/src/main/resources/application-prod.yml index e69de29b..dfe83613 100644 --- a/domain/src/main/resources/application-prod.yml +++ b/domain/src/main/resources/application-prod.yml @@ -0,0 +1,23 @@ +spring: + datasource: + url: jdbc:mysql://${DB_HOSTNAME}:${DB_PORT}/${DB_DATABASE} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + show-sql: true + + flyway: + enabled: true + baseline-on-migrate: true + url: jdbc:mysql://${DB_HOSTNAME}:${DB_PORT}/${DB_DATABASE} + user: ${DB_USERNAME} + password: ${DB_PASSWORD} + baseline-version: 4 \ No newline at end of file diff --git a/domain/src/main/resources/application-test.yml b/domain/src/main/resources/application-test.yml index 7e5c6a60..8d239548 100644 --- a/domain/src/main/resources/application-test.yml +++ b/domain/src/main/resources/application-test.yml @@ -14,3 +14,6 @@ spring: format_sql: true show-sql: true + flyway: + enabled: false + diff --git a/domain/src/main/resources/application.yml b/domain/src/main/resources/application.yml index 027b4e36..03c30d37 100644 --- a/domain/src/main/resources/application.yml +++ b/domain/src/main/resources/application.yml @@ -1,3 +1,3 @@ spring: profiles: - active: test \ No newline at end of file + active: test diff --git a/domain/src/main/resources/db/migration/V1__init.sql b/domain/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 00000000..9531faf9 --- /dev/null +++ b/domain/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,30 @@ +create table stock +( + id binary (16) not null + primary key, + price double, + volume int, + created_at datetime(6), + last_modified_at datetime(6), + exchange varchar(100), + ticker varchar(100) not null, + industry varchar(255), + name varchar(255), + sector enum('TECHNOLOGY', 'COMMUNICATION_SERVICES', 'HEALTHCARE', 'CONSUMER_CYCLICAL', 'CONSUMER_DEFENSIVE', 'BASIC_MATERIALS', 'FINANCIAL_SERVICES', 'INDUSTRIALS', 'REAL_ESTATE', 'ENERGY', 'UTILITIES', 'INDUSTRIAL_GOODS', 'FINANCIAL', 'SERVICES', 'CONGLOMERATES', 'ETC') +) engine = innodb + default charset = utf8mb4; + +create table if not exists dividend +( + id binary (16) not null, + created_at datetime(6), + last_modified_at datetime(6), + declaration_date datetime(6) not null, + dividend integer not null, + ex_dividend_date datetime(6) not null, + payment_date datetime(6) not null, + stock_id binary (16) not null, + FOREIGN KEY (stock_id) REFERENCES stock (id), + primary key (id) +) engine = innodb + default charset = utf8mb4; \ No newline at end of file diff --git a/domain/src/main/resources/db/migration/V2__column_update.sql b/domain/src/main/resources/db/migration/V2__column_update.sql new file mode 100644 index 00000000..d65d2a64 --- /dev/null +++ b/domain/src/main/resources/db/migration/V2__column_update.sql @@ -0,0 +1,24 @@ +alter table stock + modify exchange varchar (100) null; + +alter table stock + modify ticker varchar (100) null; + +alter table stock + modify name varchar (255) null; + +alter table dividend + modify dividend double null; + +alter table dividend + modify declaration_date datetime(6) null; + +alter table dividend + modify payment_date datetime(6) null; + +alter table dividend + modify ex_dividend_date datetime(6) null; + + + + diff --git a/domain/src/main/resources/db/migration/V3__add_stock_logo_column.sql b/domain/src/main/resources/db/migration/V3__add_stock_logo_column.sql new file mode 100644 index 00000000..413a267c --- /dev/null +++ b/domain/src/main/resources/db/migration/V3__add_stock_logo_column.sql @@ -0,0 +1,2 @@ +alter table stock + add logo_url text null; \ No newline at end of file diff --git a/domain/src/main/resources/db/migration/V4__add_sector.sql b/domain/src/main/resources/db/migration/V4__add_sector.sql new file mode 100644 index 00000000..5e345d65 --- /dev/null +++ b/domain/src/main/resources/db/migration/V4__add_sector.sql @@ -0,0 +1,2 @@ +alter table stock + modify sector enum ('TECHNOLOGY', 'COMMUNICATION_SERVICES', 'HEALTHCARE', 'CONSUMER_CYCLICAL', 'CONSUMER_DEFENSIVE', 'BASIC_MATERIALS', 'FINANCIAL_SERVICES', 'INDUSTRIALS', 'REAL_ESTATE', 'ENERGY', 'UTILITIES', 'INDUSTRIAL_GOODS', 'FINANCIAL', 'SERVICES', 'CONGLOMERATES', 'ETC', 'ETF') null; diff --git a/domain/src/test/java/nexters/dividend/domain/DomainApplicationTests.java b/domain/src/test/java/nexters/payout/domain/DomainApplicationTests.java similarity index 85% rename from domain/src/test/java/nexters/dividend/domain/DomainApplicationTests.java rename to domain/src/test/java/nexters/payout/domain/DomainApplicationTests.java index 21429bb8..169944eb 100644 --- a/domain/src/test/java/nexters/dividend/domain/DomainApplicationTests.java +++ b/domain/src/test/java/nexters/payout/domain/DomainApplicationTests.java @@ -1,4 +1,4 @@ -package nexters.dividend.domain; +package nexters.payout.domain; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; diff --git a/domain/src/test/java/nexters/payout/domain/stock/service/SectorAnalysisServiceTest.java b/domain/src/test/java/nexters/payout/domain/stock/service/SectorAnalysisServiceTest.java new file mode 100644 index 00000000..af911184 --- /dev/null +++ b/domain/src/test/java/nexters/payout/domain/stock/service/SectorAnalysisServiceTest.java @@ -0,0 +1,60 @@ +package nexters.payout.domain.stock.service; + +import nexters.payout.domain.StockFixture; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +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.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.within; +import static org.junit.jupiter.api.Assertions.assertAll; + +class SectorAnalysisServiceTest { + + @Test + void 하나의_티커가_존재하는_경우_섹터비율_검증() { + // given + Stock stock = StockFixture.createStock(StockFixture.AAPL, Sector.TECHNOLOGY, 3.0); + List stockShares = List.of(new StockShare(stock, 1)); + SectorAnalysisService sectorAnalysisService = new SectorAnalysisService(); + + // when + Map actual = sectorAnalysisService.calculateSectorRatios(stockShares); + + // then + assertAll( + () -> assertThat(actual).hasSize(1), + () -> assertThat(actual.get(Sector.TECHNOLOGY)).isEqualTo(new SectorInfo(1.0, List.of(new StockShare(stock, 1)))) + ); + } + + @Test + void 서로_다른_섹터와_개수와_현재가를_가진_2개의_티커가_존재하는_경우_섹터비율_검증() { + // given + Stock appl = StockFixture.createStock(StockFixture.AAPL, Sector.TECHNOLOGY, 4.0); + Stock tsla = StockFixture.createStock(StockFixture.TSLA, Sector.CONSUMER_CYCLICAL, 1.0); + List stockShares = List.of(new StockShare(appl, 2), new StockShare(tsla, 1)); + SectorAnalysisService sectorAnalysisService = new SectorAnalysisService(); + + // when + Map actual = sectorAnalysisService.calculateSectorRatios(stockShares); + + // then + SectorInfo actualFinancialSectorInfo = actual.get(Sector.TECHNOLOGY); + SectorInfo actualTechnologySectorInfo = actual.get(Sector.CONSUMER_CYCLICAL); + + assertAll( + () -> assertThat(actual).hasSize(2), + () -> assertThat(actualFinancialSectorInfo.ratio()).isCloseTo(0.8889, within(0.001)), + () -> assertThat(actualFinancialSectorInfo.stockShares()).isEqualTo(List.of(new StockShare(appl, 2))), + () -> assertThat(actualTechnologySectorInfo.ratio()).isCloseTo(0.1111, within(0.001)), + () -> assertThat(actualTechnologySectorInfo.stockShares()).isEqualTo(List.of(new StockShare(tsla, 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 new file mode 100644 index 00000000..d31b555f --- /dev/null +++ b/domain/src/test/java/nexters/payout/domain/stock/service/StockDividendAnalysisServiceTest.java @@ -0,0 +1,160 @@ +package nexters.payout.domain.stock.service; + +import nexters.payout.domain.DividendFixture; +import nexters.payout.domain.StockFixture; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.domain.service.StockDividendAnalysisService; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.Month; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static java.time.ZoneOffset.UTC; +import static org.assertj.core.api.Assertions.assertThat; + +class StockDividendAnalysisServiceTest { + + StockDividendAnalysisService stockDividendAnalysisService = new StockDividendAnalysisService(); + + @Test + void 작년_배당_월_리스트를_정상적으로_반환한다() { + // given + Stock stock = StockFixture.createStock(StockFixture.AAPL, Sector.TECHNOLOGY); + int lastYear = LocalDate.now(UTC).getYear() - 1; + Instant janPaymentDate = LocalDate.of(lastYear, 1, 3).atStartOfDay().toInstant(UTC); + Instant aprPaymentDate = LocalDate.of(lastYear, 4, 3).atStartOfDay().toInstant(UTC); + Instant julPaymentDate = LocalDate.of(lastYear, 7, 3).atStartOfDay().toInstant(UTC); + Instant fakePaymentDate = LocalDate.of(LocalDate.now().getYear(), 8, 3).atStartOfDay().toInstant(UTC); + + Dividend janDividend = DividendFixture.createDividendWithExDividendDate(stock.getId(), janPaymentDate); + Dividend aprDividend = DividendFixture.createDividendWithExDividendDate(stock.getId(), aprPaymentDate); + Dividend julDividend = DividendFixture.createDividendWithExDividendDate(stock.getId(), julPaymentDate); + Dividend fakeDividend = DividendFixture.createDividendWithExDividendDate(stock.getId(), fakePaymentDate); + + // when + List actual = stockDividendAnalysisService.calculateDividendMonths(stock, List.of(janDividend, aprDividend, julDividend, fakeDividend)); + + // then + assertThat(actual).isEqualTo(List.of(Month.JANUARY, Month.APRIL, Month.JULY)); + } + + @Test + void 작년_배당_기록이_없는_경우_빈_리스트를_반환한다() { + // given + Stock stock = StockFixture.createStock(StockFixture.AAPL, Sector.TECHNOLOGY); + Instant fakePaymentDate = LocalDate.of(LocalDate.now().getYear(), 8, 3).atStartOfDay().toInstant(UTC); + + Dividend fakeDividend = DividendFixture.createDividendWithExDividendDate(stock.getId(), fakePaymentDate); + + // when + List actual = stockDividendAnalysisService.calculateDividendMonths(stock, List.of(fakeDividend)); + + // then + assertThat(actual).isEmpty(); + } + + @Test + void 배당_기록이_없는_경우_빈_리스트를_반환한다() { + // given + Stock stock = StockFixture.createStock(StockFixture.AAPL, Sector.TECHNOLOGY); + + // when + List actual = stockDividendAnalysisService.calculateDividendMonths(stock, List.of()); + + // then + assertThat(actual).isEmpty(); + } + + @Test + void 공시된_현재_배당금_지급일이_없는_경우_과거데이터를_기반으로_가까운_지급일을_계산한다() { + // given + LocalDate now = LocalDate.now(); + + Dividend pastDividend = DividendFixture.createDividendWithExDividendDate( + UUID.randomUUID(), + LocalDate.of(now.getYear() - 1, 1, 10) + .atStartOfDay(ZoneId.systemDefault()).toInstant() + ); + + Dividend earlistDividend = DividendFixture.createDividendWithExDividendDate( + UUID.randomUUID(), + LocalDate.of(now.getYear() - 1, 3, 10) + .atStartOfDay(ZoneId.systemDefault()).toInstant() + ); + List lastYearDividends = List.of(pastDividend, earlistDividend); + + // when + Optional actual = stockDividendAnalysisService.findUpcomingDividend(lastYearDividends, Collections.emptyList()); + + // then + assertThat(actual.get()).isEqualTo(earlistDividend); + } + + @Test + void 공시된_현재_배당금_지급일이_존재하는_경우_실제_지급일을_반환한다() { + // given + LocalDate now = LocalDate.now(); + int plusDay = Math.max(now.getDayOfMonth(), now.plusDays(3).getDayOfMonth()); + + Dividend lastYearDividend = DividendFixture.createDividend( + UUID.randomUUID(), + 1.0, + LocalDate.now().plusDays(10) + .atStartOfDay(ZoneId.systemDefault()).toInstant() + ); + + Dividend thisYearDividend = DividendFixture.createDividend( + UUID.randomUUID(), + 1.0, + LocalDate.now().plusDays(3) + .atStartOfDay(ZoneId.systemDefault()).toInstant() + ); + + List lastYearDividends = List.of(lastYearDividend); + List thisYearDividends = List.of(thisYearDividend); + + // when + Optional actual = stockDividendAnalysisService.findUpcomingDividend(lastYearDividends, thisYearDividends); + + // then + assertThat(actual.get()).isEqualTo(thisYearDividend); + } + + @Test + void 배당수익률을_구할수있다() { + // given + Stock aapl = StockFixture.createStock(StockFixture.AAPL, Sector.TECHNOLOGY, 40.0); + List dividends = List.of(DividendFixture.createDividendWithDividend(UUID.randomUUID(), 10.0), + DividendFixture.createDividendWithDividend(UUID.randomUUID(), 20.0) + ); + Double expected = 30.0 / 40.0; + + // when + Double actual = stockDividendAnalysisService.calculateDividendYield(aapl, dividends); + + // then + assertThat(actual).isEqualTo(expected); + } + + @Test + void 배당금_리스트로부터_평균_배당금을_구할수있다() { + // given + List dividends = List.of(DividendFixture.createDividendWithDividend(UUID.randomUUID(), 10.0), + DividendFixture.createDividendWithDividend(UUID.randomUUID(), 20.0) + ); + + // when + Double actual = stockDividendAnalysisService.calculateAverageDividend(dividends); + + // then + assertThat(actual).isEqualTo(15.0); + } +} \ No newline at end of file diff --git a/domain/src/testFixtures/java/nexters/payout/domain/DividendFixture.java b/domain/src/testFixtures/java/nexters/payout/domain/DividendFixture.java new file mode 100644 index 00000000..606afe99 --- /dev/null +++ b/domain/src/testFixtures/java/nexters/payout/domain/DividendFixture.java @@ -0,0 +1,70 @@ +package nexters.payout.domain; + +import nexters.payout.domain.dividend.domain.Dividend; + +import java.time.Instant; +import java.util.UUID; + +public class DividendFixture { + + public static Dividend createDividendWithDividend(UUID stockId, Double dividend) { + return new Dividend( + UUID.randomUUID(), + stockId, + dividend, + Instant.now(), + Instant.now(), + Instant.now() + ); + } + + public static Dividend createDividendWithExDividendDate(UUID stockId, Instant exDividendDate) { + return new Dividend( + UUID.randomUUID(), + stockId, + 12.21, + exDividendDate, + Instant.parse("2023-12-21T00:00:00Z"), + Instant.parse("2023-12-22T00:00:00Z")); + } + + public static Dividend createDividendWithPaymentDate(UUID stockId, Double dividend, Instant paymentDate) { + return new Dividend( + UUID.randomUUID(), + stockId, + dividend, + paymentDate, + paymentDate, + paymentDate); + } + + public static Dividend createDividend(UUID stockId, Double dividend, Instant exDividendDate) { + return new Dividend( + UUID.randomUUID(), + stockId, + dividend, + exDividendDate, + exDividendDate, + exDividendDate); + } + + public static Dividend createDividend(UUID stockId) { + return new Dividend( + UUID.randomUUID(), + stockId, + 12.21, + Instant.parse("2023-12-21T00:00:00Z"), + Instant.parse("2023-12-23T00:00:00Z"), + Instant.parse("2023-12-22T00:00:00Z")); + } + + public static Dividend createDividendWithNullDate(UUID stockId) { + return new Dividend( + UUID.randomUUID(), + stockId, + 12.21, + Instant.parse("2023-12-21T00:00:00Z"), + null, + null); + } +} diff --git a/domain/src/testFixtures/java/nexters/payout/domain/StockFixture.java b/domain/src/testFixtures/java/nexters/payout/domain/StockFixture.java new file mode 100644 index 00000000..a062bd0d --- /dev/null +++ b/domain/src/testFixtures/java/nexters/payout/domain/StockFixture.java @@ -0,0 +1,29 @@ +package nexters.payout.domain; + +import nexters.payout.domain.stock.domain.Exchange; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; + +import java.util.UUID; + +public class StockFixture { + public static final String TSLA = "TSLA"; + public static final String AAPL = "AAPL"; + public static final String SBUX = "SBUX"; + + public static Stock createStock(String ticker, Double price, Integer volume) { + return new Stock(ticker, "tesla", Sector.FINANCIAL_SERVICES, Exchange.NYSE.name(), "industry", price, volume, ""); + } + + public static Stock createStock(String ticker, Sector sector) { + return new Stock(UUID.randomUUID(), ticker, ticker, sector, Exchange.NYSE.name(), "industry", 0.0, 0, ""); + } + + public static Stock createStock(String ticker, String companyName) { + return new Stock(UUID.randomUUID(), ticker, companyName, Sector.TECHNOLOGY, Exchange.NYSE.name(), "industry", 0.0, 0, ""); + } + + public static Stock createStock(String ticker, Sector sector, Double price) { + return new Stock(UUID.randomUUID(), ticker, ticker, sector, Exchange.NYSE.name(), "industry", price, 0, ""); + } +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 00000000..dba09173 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,36 @@ +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + sendfile on; + keepalive_timeout 65; + + server { + listen 80; + # listen [::]:80; + + if ($http_x_forwarded_proto != 'https'){ + return 301 https://$host$request_uri; + } + + location /health { + return 200 'ok'; + add_header Content-Type text/plain; + } + + location / { + proxy_pass http://green-api:8080; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 8578c629..0888352c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,4 @@ -rootProject.name = 'dividend-server' +rootProject.name = 'payout-server' include(":api-server") include(":batch")