diff --git a/docs/20260226_channel_donation_settlement_index_ddl.sql b/docs/20260226_channel_donation_settlement_index_ddl.sql new file mode 100644 index 00000000..4407736b --- /dev/null +++ b/docs/20260226_channel_donation_settlement_index_ddl.sql @@ -0,0 +1,49 @@ +SET @schema_name := DATABASE(); + +SET @use_can_index_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = @schema_name + AND table_name = 'use_can' + AND index_name = 'idx_use_can_channel_donation_filter' +); +SET @use_can_index_sql := IF( + @use_can_index_exists = 0, + 'ALTER TABLE use_can ADD INDEX idx_use_can_channel_donation_filter (can_usage, is_refund, created_at, id)', + 'SELECT "idx_use_can_channel_donation_filter already exists"' +); +PREPARE use_can_index_stmt FROM @use_can_index_sql; +EXECUTE use_can_index_stmt; +DEALLOCATE PREPARE use_can_index_stmt; + +SET @use_can_calculate_join_index_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = @schema_name + AND table_name = 'use_can_calculate' + AND index_name = 'idx_use_can_calculate_settlement_join' +); +SET @use_can_calculate_join_index_sql := IF( + @use_can_calculate_join_index_exists = 0, + 'ALTER TABLE use_can_calculate ADD INDEX idx_use_can_calculate_settlement_join (use_can_id, status, recipient_creator_id)', + 'SELECT "idx_use_can_calculate_settlement_join already exists"' +); +PREPARE use_can_calculate_join_index_stmt FROM @use_can_calculate_join_index_sql; +EXECUTE use_can_calculate_join_index_stmt; +DEALLOCATE PREPARE use_can_calculate_join_index_stmt; + +SET @use_can_calculate_creator_index_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = @schema_name + AND table_name = 'use_can_calculate' + AND index_name = 'idx_use_can_calculate_creator_settlement' +); +SET @use_can_calculate_creator_index_sql := IF( + @use_can_calculate_creator_index_exists = 0, + 'ALTER TABLE use_can_calculate ADD INDEX idx_use_can_calculate_creator_settlement (recipient_creator_id, status, use_can_id)', + 'SELECT "idx_use_can_calculate_creator_settlement already exists"' +); +PREPARE use_can_calculate_creator_index_stmt FROM @use_can_calculate_creator_index_sql; +EXECUTE use_can_calculate_creator_index_stmt; +DEALLOCATE PREPARE use_can_calculate_creator_index_stmt; diff --git a/docs/20260226_관리자크리에이터관리자채널후원정산페이지api생성.md b/docs/20260226_관리자크리에이터관리자채널후원정산페이지api생성.md new file mode 100644 index 00000000..4fa2135a --- /dev/null +++ b/docs/20260226_관리자크리에이터관리자채널후원정산페이지api생성.md @@ -0,0 +1,191 @@ +# 관리자/크리에이터 관리자 채널 후원 정산 페이지 API 작업 계획 + +- [x] 기존 정산 API 패턴(`admin.calculate`, `creator.admin.calculate`)과 채널 후원 데이터 소스(`ChannelDonationMessage`, `CanUsage.CHANNEL_DONATION`)를 확인한다. +- [x] 기존 패키지에 직접 누적하지 않도록 신규 하위 패키지를 설계한다. + - 관리자: `kr.co.vividnext.sodalive.admin.calculate.channelDonation` + - 크리에이터 관리자: `kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation` +- [x] 관리자 채널 후원 정산 조회 API를 추가하고, 날짜 범위(`startDateStr`, `endDateStr`)로 전체 데이터를 조회한 뒤 응답을 크리에이터별로 그룹화해 반환하도록 설계한다. +- [x] 크리에이터 관리자 채널 후원 정산 조회 API를 추가하고, 날짜 범위(`startDateStr`, `endDateStr`)만 입력받아 인증 사용자 본인 데이터만 조회한다. +- [x] 서비스 계층에서 날짜 문자열을 `convertLocalDateTime()`으로 변환하고 종료일은 `23:59:59`로 보정해 조회 구간을 통일한다. +- [x] 저장소(QueryRepository) 계층에 날짜 범위 조건(`createdAt >= startDate`, `createdAt <= endDate`)과 크리에이터 기준 그룹화(`groupBy(member.id)` 등)를 반영한 집계 조회를 추가한다. +- [x] API URL을 기존 정산 URL 규칙에 맞춰 확정하고 문서화한다. + - 관리자: `GET /admin/calculate/channel-donation-by-creator` + - 크리에이터 관리자: `GET /creator-admin/calculate/channel-donation` +- [x] 정산 계산 공식을 공통 로직으로 구현하고, 사람이 이해하기 쉬운 한글 주석을 추가한다. + - 원화 = 캔 * 100 + - 수수료 = 원화 * 6.6% + - 정산금액 = (원화 - 수수료) * 85% + - 원천세 = 정산금액 * 3.3% + - 입금액 = 정산금액 - 원천세 +- [x] 계산 정밀도 정책을 정의한다(`BigDecimal`, `RoundingMode.HALF_UP`, 반올림 시점 고정). +- [x] 성능/효율 개선 항목을 반영한다(집계 쿼리 중심 처리, 불필요한 애플리케이션 후처리 최소화, count 조회 최적화 검토). +- [x] 응답 DTO 스펙을 아래 필드로 고정하고 권한 정책(관리자=전체, 크리에이터 관리자=본인)을 함께 검증한다. + - 날짜(`yyyy-MM-dd`) + - 크리에이터 + - 건수(`count`) + - 총 받은 캔 수(`totalCan`) + - 원화 + - 수수료 + - 정산금액 + - 원천세 + - 입금액 +- [x] 테스트를 추가한다(관리자 날짜 필터 + 크리에이터별 그룹화 응답, 크리에이터 관리자 날짜 필터/본인 범위, 계산식 정확성, 경계값). +- [x] 검증을 수행한다(`./gradlew test`, `./gradlew build`, 필요 시 `./gradlew ktlintCheck`). + +## API URL 선정 근거 + +- 기본 경로는 권한 범위별 정산 컨트롤러 관례를 따른다. + - 관리자: `@RequestMapping("/admin/calculate")` + - 크리에이터 관리자: `@RequestMapping("/creator-admin/calculate")` +- 하위 경로는 기존 정산 API와 동일하게 소문자 하이픈(`kebab-case`) 명사 조합을 사용한다. + - 예: `content-donation-list`, `cumulative-sales-by-content`, `community-by-creator` +- `channel-donation` 토큰은 기존 채널 후원 API 경로(`@RequestMapping("/explorer/profile/channel-donation")`)와 용어를 맞춰 도메인 표현을 통일한다. +- 관리자 정산은 조회 결과가 크리에이터별 그룹화 응답이므로 기존 `*-by-creator` 패턴을 적용해 `channel-donation-by-creator`로 정한다. +- 크리에이터 관리자 정산은 인증 사용자 본인 범위로 고정되므로 `-by-creator` 접미사를 제외하고 `channel-donation`으로 정한다. + +## 검증 기록 + +### 계획 수립 +- 무엇을: 관리자/크리에이터 관리자 채널 후원 정산 페이지 API 구현을 위한 작업 계획 문서를 작성했다. +- 왜: 구현 전에 패키지 구조, 날짜 범위 조회, 정산 계산식, 성능 검증 기준을 명확히 하기 위해서다. +- 어떻게: + - `docs`의 기존 작업 계획 문서 형식(체크박스 + 검증 기록)을 기준으로 템플릿을 맞췄다. + - `admin.calculate`, `creator.admin.calculate`, `explorer.profile.channelDonation` 경로를 탐색해 반영했다. + - 사용자 요청에 따라 실제 코드 구현/테스트는 수행하지 않고 계획 문서만 작성했다. + +### 2차 계획 수정 +- 무엇을: 조회 조건을 `관리자=날짜+크리에이터 구분`, `크리에이터 관리자=날짜만`으로 명확히 분리했고, 응답 필드를 `날짜(yyyy-MM-dd), 크리에이터, 원화, 수수료, 정산금액, 원천세, 입금액`으로 고정했다. +- 왜: 추가 요구사항(조회 조건 분리, Response 필드 고정)을 계획 단계에서 누락 없이 반영하기 위해서다. +- 어떻게: + - `admin.calculate`/`creator.admin.calculate`의 기존 날짜 파라미터 및 인증 기반 필터링 패턴을 재탐색해 계획 항목을 수정했다. + - `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다. + - `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. + - 문서만 작성해야 하는 요청 범위를 유지하기 위해 코드 구현/테스트 변경은 수행하지 않았다. + +### 3차 계획 수정 +- 무엇을: 관리자 조회 요구사항을 `크리에이터 식별값으로 필터`가 아닌 `조회 결과를 크리에이터별로 그룹화하여 반환`으로 정정했다. +- 왜: 사용자 의도가 “조회 조건 추가”가 아니라 “응답 결과 구성 방식(크리에이터별 그룹화)”이었기 때문이다. +- 어떻게: + - `AdminCalculateController`의 `*-by-creator` 엔드포인트가 날짜/페이지 파라미터만 받고(`creatorId/memberId` 미입력), 서비스/리포지토리에서 `GetCalculateByCreatorResponse`와 `groupBy(member.id)` 기반으로 결과를 구성하는 패턴을 확인했다. + - 위 근거를 바탕으로 체크리스트를 `관리자=날짜 필터 + 크리에이터별 그룹화 응답` 기준으로 수정했다. + - `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다. + - `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. + +### 4차 계획 수정 +- 무엇을: 작업 계획 문서에 API URL을 어떤 기준으로 정했는지(경로 규칙, 용어 선택, 최종 URL) 근거를 추가했다. +- 왜: 구현 전에 URL 명명 기준을 명확히 남겨, 이후 개발 시 경로 해석 차이와 재작업을 방지하기 위해서다. +- 어떻게: + - `AdminCalculateController`, `CreatorAdminCalculateController`, `ChannelDonationController`의 `@RequestMapping`/`@GetMapping` 패턴을 비교해 기준 경로와 하위 경로 규칙을 도출했다. + - 관리자 URL은 `*-by-creator` 관례를 적용해 `/admin/calculate/channel-donation-by-creator`, 크리에이터 관리자 URL은 본인 범위 고정 특성에 맞춰 `/creator-admin/calculate/channel-donation`으로 문서화했다. + - `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다. + - `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. + +### 5차 구현 +- 무엇을: 관리자/크리에이터 관리자 채널 후원 정산 API를 신규 하위 패키지로 구현하고, 날짜 범위 조회/크리에이터별 그룹화/정산 공식 공통 계산 로직을 적용했다. +- 왜: 기존 정산 코드에 얽히지 않고 유지보수 가능한 구조로 요구사항(관리자=크리에이터별 그룹 응답, 크리에이터 관리자=본인 범위 조회)을 정확히 반영하기 위해서다. +- 어떻게: + - 신규 패키지 생성: `admin.calculate.channelDonation`, `creator.admin.calculate.channelDonation`, 공통 계산기 `calculate.channelDonation`. + - API 구현: `GET /admin/calculate/channel-donation-by-creator`, `GET /creator-admin/calculate/channel-donation`. + - QueryDSL 집계: `UseCan` + `UseCanCalculate`를 사용해 `CanUsage.CHANNEL_DONATION`, 날짜 범위, 환불 제외 조건을 적용하고 관리자 응답은 날짜+크리에이터 기준 그룹화, 크리에이터 관리자 응답은 날짜 기준 그룹화로 구현. + - 정산 계산식 공통화: `ChannelDonationSettlementCalculator`에서 `BigDecimal("0.066")`, `BigDecimal("0.85")`, `BigDecimal("0.033")`, `RoundingMode.HALF_UP` 정책으로 계산하고 공식 설명 한글 주석을 추가. + - 테스트 추가: 계산식/반올림 단위 테스트 및 관리자·크리에이터 관리자 컨트롤러/서비스 경로 테스트를 추가. + - 검증 실행: + - `./gradlew test --tests "kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculatorTest"` → 성공 + - `./gradlew test --tests "*channelDonation*"` → 성공 + - `./gradlew test` → 성공 + - `./gradlew build` → 성공 + - 참고: Kotlin LSP 서버 미설정 환경이라 `.kt` 파일에 대한 `lsp_diagnostics`는 실행 시 서버 미설정 오류를 반환했다. + +### 6차 수정 +- 무엇을: 정산 계산식을 단계별 반올림 후 다음 단계 계산하는 방식으로 수정하고, 크리에이터 관리자 조회 쿼리/카운트에서 불필요한 `member` 조인을 제거했다. +- 왜: 정산 항목 간 관계(`입금액 = 정산금액 - 원천세`)를 정수 기준으로 일관되게 맞추고, 조회 성능 최적화를 위해 불필요 조인을 줄이기 위해서다. +- 어떻게: + - `ChannelDonationSettlementCalculator`를 단계별 반올림 파이프라인으로 변경했다. + - `수수료 = round(원화 * 6.6%)` + - `정산금액 = round((원화 - 수수료) * 85%)` + - `원천세 = round(정산금액 * 3.3%)` + - `입금액 = 정산금액 - 원천세` + - 크리에이터 관리자 경로는 인증 사용자 닉네임을 서비스 인자로 전달해 응답 `creator`를 구성하고, QueryRepository의 `member` 조인/닉네임 select를 제거했다. + - 관리자 totalCount는 `member` 조인 없이 `recipientCreatorId` 기반 distinct 키로 계산하도록 변경했다. +- 검증 실행: + - `./gradlew test --tests "*channelDonation*"` → 성공 + - `./gradlew test` → 성공 + - `./gradlew build` → 성공 + +### 7차 수정 +- 무엇을: 요청한 2번/3번 최적화를 반영해 QueryDSL `@QueryProjection` 기반 매핑으로 전환하고, 날짜 그룹 조회 경로 인덱스 전략 DDL을 추가했다. 또한 테스트 가독성을 위해 `@DisplayName`을 추가했다. +- 왜: `Projections.constructor` 대비 타입 안전성과 유지보수성을 높이고, 채널 후원 정산 조회의 날짜 범위/조인 필터 성능 개선 근거를 DDL로 명확히 남기기 위해서다. +- 어떻게: + - Query DTO 전환: + - `GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`에 `@QueryProjection`을 적용했다. + - 각 QueryRepository의 `Projections.constructor`를 `QGet*QueryData(...)` 호출로 교체했다. + - 인덱스 전략 반영: + - `docs/20260226_channel_donation_settlement_index_ddl.sql` 파일을 추가해 `use_can`, `use_can_calculate` 인덱스 DDL을 정의했다. + - 테스트 가독성 개선: + - 채널 후원 정산 관련 신규 테스트에 `@DisplayName`(한글)을 추가해 테스트 의도를 명확히 했다. + - 검증 실행: + - `./gradlew test --tests "*channelDonation*"` → 성공 + - `./gradlew test` → 성공 + - `./gradlew build` → 성공 + - 참고: `./gradlew test`와 `./gradlew build`를 병렬 실행하면 테스트 결과 XML 파일 쓰기 충돌이 재발할 수 있어, 순차 실행 기준으로 최종 검증했다. + +### 8차 수정 +- 무엇을: 채널 후원 정산 Item 응답(`GetAdminChannelDonationSettlementItem`, `GetCreatorChannelDonationSettlementItem`)에 `count` 필드를 추가하고, QueryData/Repository/Test를 함께 갱신했다. +- 왜: 사용자 요청에 따라 정산 응답에서 그룹별 건수를 직접 확인할 수 있도록 하기 위해서다. +- 어떻게: + - Item DTO에 `@JsonProperty("count") val count: Int`를 추가했다. + - QueryDTO(`GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`)에 `count: Long`을 추가하고 `toResponseItem()`에서 `count.toInt()`로 매핑했다. + - Repository projection에 `useCan.id.count()`를 추가해 count 값을 조회하도록 반영했다. + - 컨트롤러/서비스 테스트 fixture 및 assertion에 `count` 검증을 추가했다. + - 검증 실행: + - `./gradlew test --tests "*channelDonation*"` → 성공 + - `./gradlew test` → 성공 + - `./gradlew build` → 성공 + +### 9차 수정 +- 무엇을: 채널 후원 정산 `count`가 분할 정산 레코드 수로 과집계되던 문제를 수정하고, 동일 후원(`UseCan` 1건) + 분할 정산(`UseCanCalculate` 2건) 회귀 테스트를 관리자/크리에이터 관리자 경로에 추가했다. +- 왜: 결제 게이트웨이별 분할 정산이 발생하면 기존 `useCan.id.count()`가 실제 후원 건수보다 크게 집계되어 정산 화면 `count`가 잘못 표시되기 때문이다. +- 어떻게: + - `AdminChannelDonationCalculateQueryRepository`, `CreatorAdminChannelDonationCalculateQueryRepository`의 집계 `count`를 `useCan.id.countDistinct()`로 변경했다. + - QueryRepository 통합 테스트(`AdminChannelDonationCalculateQueryRepositoryTest`, `CreatorAdminChannelDonationCalculateQueryRepositoryTest`)를 추가해 분할 정산 시 `count=1`, `totalCan` 합산(50) 동작을 검증했다. + - H2 환경에서 MySQL 함수(`DATE_FORMAT`, `CONVERT_TZ`)를 테스트 가능하게 하기 위해 `H2MySqlFunctionDialect`, `H2MysqlDateFunctions` 테스트 지원 코드를 추가하고 각 테스트에서 alias를 등록했다. + - 검증 실행: + - `./gradlew test --tests "*channelDonation*"` → 성공 + - `./gradlew build` → 성공 + - 참고: Kotlin LSP 미설정 환경이라 `.kt` 대상 `lsp_diagnostics`는 실행 시 서버 미설정 오류가 발생했다. + +### 10차 수정 +- 무엇을: 관리자 채널 후원 정산의 `totalCount` 쿼리에 `member` `innerJoin`을 추가해 목록 조회와 동일한 조인 조건으로 집계하도록 정렬했다. +- 왜: 기존에는 `totalCount`는 `member` 조인 없이 계산하고 목록은 `member` `innerJoin`을 사용해, 데이터 정합성 이슈(고아 `recipientCreatorId`)가 있을 때 `totalCount`와 `items`가 불일치할 수 있었다. +- 어떻게: + - `AdminChannelDonationCalculateQueryRepository.getChannelDonationByCreatorTotalCount(...)`에 `member` 조인(`member.id = useCanCalculate.recipientCreatorId`)을 추가했다. + - distinct 그룹 키를 `recipientCreatorId` 문자열 대신 `member.id` 문자열 기준으로 변경해 목록 쿼리의 그룹 축(날짜+멤버)과 맞췄다. + - 검증 실행: + - `./gradlew test --tests "*channelDonation*"` → 성공 + - `./gradlew build` → 성공 + - 참고: `./gradlew test`와 `./gradlew build`를 병렬 실행했을 때 test result XML 쓰기 충돌이 1회 발생해, 이후 순차 실행으로 재검증했다. + +### 10차 수정 +- 무엇을: 정산 페이지 Item 응답(`GetAdminChannelDonationSettlementItem`, `GetCreatorChannelDonationSettlementItem`)에 `totalCan` 필드를 추가했다. +- 왜: 사용자 요청대로 화면에서 건수 다음에 총 받은 캔 수를 함께 노출하기 위해서다. +- 어떻게: + - Item DTO에 `@JsonProperty("totalCan") val totalCan: Int`를 `count` 다음 위치로 추가했다. + - QueryData(`GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`)의 `toResponseItem()`에서 `totalCan ?: 0`을 응답 Item의 `totalCan`으로 매핑했다. + - 컨트롤러/서비스 테스트 fixture와 assertion에 `totalCan` 검증을 추가했다. +- 검증 실행: + - `./gradlew test --tests "*channelDonation*"` → 성공 + - `./gradlew test` → 성공 + - `./gradlew build` → 성공 + +### 11차 수정 +- 무엇을: 채널 후원 정산 인덱스 DDL(`docs/20260226_channel_donation_settlement_index_ddl.sql`)을 재실행 가능한 멱등 스크립트로 수정했다. +- 왜: 동일 DB에 DDL을 재적용할 때 기존 `ADD INDEX`가 `Duplicate key name`으로 실패할 수 있어, 운영 재적용/롤백 후 재적용 시 안정성을 확보해야 했기 때문이다. +- 어떻게: + - `information_schema.statistics`에서 `table_schema = DATABASE()` 기준으로 인덱스 존재 여부를 조회하도록 변경했다. + - 인덱스가 없을 때만 `ALTER TABLE ... ADD INDEX`를 실행하고, 이미 존재하면 안내 `SELECT`를 실행하는 동적 SQL(`PREPARE`/`EXECUTE`) 패턴을 적용했다. + - 대상 인덱스 3개(`idx_use_can_channel_donation_filter`, `idx_use_can_calculate_settlement_join`, `idx_use_can_calculate_creator_settlement`) 모두 동일 규칙으로 반영했다. +- 검증 실행: + - `lsp_diagnostics`(대상: `docs/20260226_channel_donation_settlement_index_ddl.sql`) → `.sql` LSP 서버 미설정으로 진단 불가(환경 제약) + - `lsp_diagnostics`(대상: 본 문서) → `No diagnostics found` + - `./gradlew test --tests "*channelDonation*"` → 성공 + - `./gradlew build` → 성공 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt new file mode 100644 index 00000000..df1b74a4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.data.domain.Pageable +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@PreAuthorize("hasRole('ADMIN')") +@RequestMapping("/admin/calculate") +class AdminChannelDonationCalculateController( + private val service: AdminChannelDonationCalculateService +) { + @GetMapping("/channel-donation-by-creator") + fun getChannelDonationByCreator( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + pageable: Pageable + ) = ApiResponse.ok( + service.getChannelDonationByCreator( + startDateStr = startDateStr, + endDateStr = endDateStr, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt new file mode 100644 index 00000000..5ffe9a5f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt @@ -0,0 +1,90 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import com.querydsl.core.types.dsl.DateTimePath +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.core.types.dsl.StringTemplate +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.member.QMember.member +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class AdminChannelDonationCalculateQueryRepository( + private val queryFactory: JPAQueryFactory +) { + fun getChannelDonationByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { + val formattedDate = getFormattedDate(useCan.createdAt) + val distinctGroupKey = Expressions.stringTemplate( + "CONCAT({0}, '-', {1})", + formattedDate, + member.id.stringValue() + ) + + return queryFactory + .select(distinctGroupKey.countDistinct()) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .innerJoin(member) + .on(member.id.eq(useCanCalculate.recipientCreatorId)) + .where(baseWhereCondition(startDate, endDate)) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun getChannelDonationByCreator( + startDate: LocalDateTime, + endDate: LocalDateTime, + offset: Long, + limit: Long + ): List { + val formattedDate = getFormattedDate(useCan.createdAt) + + return queryFactory + .select( + QGetAdminChannelDonationSettlementQueryData( + formattedDate, + member.nickname, + useCan.id.countDistinct(), + useCanCalculate.can.sum() + ) + ) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .innerJoin(member) + .on(member.id.eq(useCanCalculate.recipientCreatorId)) + .where(baseWhereCondition(startDate, endDate)) + .groupBy(formattedDate, member.id) + .orderBy(formattedDate.desc(), member.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + } + + private fun baseWhereCondition( + startDate: LocalDateTime, + endDate: LocalDateTime + ) = useCan.canUsage.eq(CanUsage.CHANNEL_DONATION) + .and(useCan.isRefund.isFalse) + .and(useCanCalculate.status.eq(UseCanCalculateStatus.RECEIVED)) + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + + private fun getFormattedDate(dateTimePath: DateTimePath): StringTemplate { + return Expressions.stringTemplate( + "DATE_FORMAT({0}, {1})", + Expressions.dateTimeTemplate( + LocalDateTime::class.java, + "CONVERT_TZ({0},{1},{2})", + dateTimePath, + "UTC", + "Asia/Seoul" + ), + "%Y-%m-%d" + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt new file mode 100644 index 00000000..0292ab63 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AdminChannelDonationCalculateService( + private val repository: AdminChannelDonationCalculateQueryRepository +) { + @Transactional(readOnly = true) + fun getChannelDonationByCreator( + startDateStr: String, + endDateStr: String, + offset: Long, + limit: Long + ): GetAdminChannelDonationSettlementResponse { + val startDate = startDateStr.convertLocalDateTime() + val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) + + val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate) + val items = repository + .getChannelDonationByCreator(startDate, endDate, offset, limit) + .map { it.toResponseItem() } + + return GetAdminChannelDonationSettlementResponse(totalCount, items) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementItem.kt new file mode 100644 index 00000000..ad1a7dca --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementItem.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GetAdminChannelDonationSettlementItem( + @JsonProperty("date") val date: String, + @JsonProperty("creator") val creator: String, + @JsonProperty("count") val count: Int, + @JsonProperty("totalCan") val totalCan: Int, + @JsonProperty("krw") val krw: Int, + @JsonProperty("fee") val fee: Int, + @JsonProperty("settlementAmount") val settlementAmount: Int, + @JsonProperty("withholdingTax") val withholdingTax: Int, + @JsonProperty("depositAmount") val depositAmount: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementQueryData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementQueryData.kt new file mode 100644 index 00000000..4574b6c0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementQueryData.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator + +data class GetAdminChannelDonationSettlementQueryData @QueryProjection constructor( + val date: String, + val creator: String, + val count: Long, + val totalCan: Int? +) { + fun toResponseItem(): GetAdminChannelDonationSettlementItem { + val settlement = ChannelDonationSettlementCalculator.calculate(totalCan ?: 0) + + return GetAdminChannelDonationSettlementItem( + date = date, + creator = creator, + count = count.toInt(), + totalCan = totalCan ?: 0, + krw = settlement.krw, + fee = settlement.fee, + settlementAmount = settlement.settlementAmount, + withholdingTax = settlement.withholdingTax, + depositAmount = settlement.depositAmount + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementResponse.kt new file mode 100644 index 00000000..16fc5da4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementResponse.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +data class GetAdminChannelDonationSettlementResponse( + val totalCount: Int, + val items: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculator.kt new file mode 100644 index 00000000..c5d9f43f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculator.kt @@ -0,0 +1,50 @@ +package kr.co.vividnext.sodalive.calculate.channelDonation + +import java.math.BigDecimal +import java.math.RoundingMode + +data class ChannelDonationSettlementAmount( + val krw: Int, + val fee: Int, + val settlementAmount: Int, + val withholdingTax: Int, + val depositAmount: Int +) + +object ChannelDonationSettlementCalculator { + private val KRW_PER_CAN = BigDecimal("100") + private val FEE_RATE = BigDecimal("0.066") + private val SETTLEMENT_RATE = BigDecimal("0.85") + private val WITHHOLDING_TAX_RATE = BigDecimal("0.033") + + fun calculate(totalCan: Int): ChannelDonationSettlementAmount { + // 원화 = 캔 * 100 + val krw = BigDecimal(totalCan).multiply(KRW_PER_CAN).setScale(0, RoundingMode.HALF_UP).toInt() + + // 수수료 = 원화 * 6.6% + val fee = BigDecimal(krw).multiply(FEE_RATE).setScale(0, RoundingMode.HALF_UP).toInt() + + // 정산금액 = (원화 - 수수료) * 85% + val settlementAmount = BigDecimal(krw - fee) + .multiply(SETTLEMENT_RATE) + .setScale(0, RoundingMode.HALF_UP) + .toInt() + + // 원천세 = 정산금액 * 3.3% + val withholdingTax = BigDecimal(settlementAmount) + .multiply(WITHHOLDING_TAX_RATE) + .setScale(0, RoundingMode.HALF_UP) + .toInt() + + // 입금액 = 정산금액 - 원천세 + val depositAmount = settlementAmount - withholdingTax + + return ChannelDonationSettlementAmount( + krw = krw, + fee = fee, + settlementAmount = settlementAmount, + withholdingTax = withholdingTax, + depositAmount = depositAmount + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateController.kt new file mode 100644 index 00000000..ef29c528 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateController.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@PreAuthorize("hasRole('CREATOR')") +@RequestMapping("/creator-admin/calculate") +class CreatorAdminChannelDonationCalculateController( + private val service: CreatorAdminChannelDonationCalculateService +) { + @GetMapping("/channel-donation") + fun getChannelDonation( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + + ApiResponse.ok( + service.getChannelDonation( + startDateStr = startDateStr, + endDateStr = endDateStr, + memberId = member.id!!, + creatorNickname = member.nickname, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepository.kt new file mode 100644 index 00000000..1a3a1d8c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepository.kt @@ -0,0 +1,80 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import com.querydsl.core.types.dsl.DateTimePath +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.core.types.dsl.StringTemplate +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class CreatorAdminChannelDonationCalculateQueryRepository( + private val queryFactory: JPAQueryFactory +) { + fun getChannelDonationTotalCount(startDate: LocalDateTime, endDate: LocalDateTime, memberId: Long): Int { + val formattedDate = getFormattedDate(useCan.createdAt) + + return queryFactory + .select(formattedDate.countDistinct()) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .where(baseWhereCondition(startDate, endDate, memberId)) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun getChannelDonation( + startDate: LocalDateTime, + endDate: LocalDateTime, + memberId: Long, + offset: Long, + limit: Long + ): List { + val formattedDate = getFormattedDate(useCan.createdAt) + + return queryFactory + .select( + QGetCreatorChannelDonationSettlementQueryData( + formattedDate, + useCan.id.countDistinct(), + useCanCalculate.can.sum() + ) + ) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .where(baseWhereCondition(startDate, endDate, memberId)) + .groupBy(formattedDate) + .orderBy(formattedDate.desc()) + .offset(offset) + .limit(limit) + .fetch() + } + + private fun baseWhereCondition(startDate: LocalDateTime, endDate: LocalDateTime, memberId: Long) = useCan.canUsage.eq( + CanUsage.CHANNEL_DONATION + ) + .and(useCan.isRefund.isFalse) + .and(useCanCalculate.status.eq(UseCanCalculateStatus.RECEIVED)) + .and(useCanCalculate.recipientCreatorId.eq(memberId)) + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + + private fun getFormattedDate(dateTimePath: DateTimePath): StringTemplate { + return Expressions.stringTemplate( + "DATE_FORMAT({0}, {1})", + Expressions.dateTimeTemplate( + LocalDateTime::class.java, + "CONVERT_TZ({0},{1},{2})", + dateTimePath, + "UTC", + "Asia/Seoul" + ), + "%Y-%m-%d" + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateService.kt new file mode 100644 index 00000000..28055160 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateService.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CreatorAdminChannelDonationCalculateService( + private val repository: CreatorAdminChannelDonationCalculateQueryRepository +) { + @Transactional(readOnly = true) + fun getChannelDonation( + startDateStr: String, + endDateStr: String, + memberId: Long, + creatorNickname: String, + offset: Long, + limit: Long + ): GetCreatorChannelDonationSettlementResponse { + val startDate = startDateStr.convertLocalDateTime() + val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) + + val totalCount = repository.getChannelDonationTotalCount(startDate, endDate, memberId) + val items = repository + .getChannelDonation(startDate, endDate, memberId, offset, limit) + .map { it.toResponseItem(creatorNickname) } + + return GetCreatorChannelDonationSettlementResponse(totalCount, items) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementItem.kt new file mode 100644 index 00000000..cb58643e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementItem.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GetCreatorChannelDonationSettlementItem( + @JsonProperty("date") val date: String, + @JsonProperty("creator") val creator: String, + @JsonProperty("count") val count: Int, + @JsonProperty("totalCan") val totalCan: Int, + @JsonProperty("krw") val krw: Int, + @JsonProperty("fee") val fee: Int, + @JsonProperty("settlementAmount") val settlementAmount: Int, + @JsonProperty("withholdingTax") val withholdingTax: Int, + @JsonProperty("depositAmount") val depositAmount: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementQueryData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementQueryData.kt new file mode 100644 index 00000000..99d381b4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementQueryData.kt @@ -0,0 +1,26 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator + +data class GetCreatorChannelDonationSettlementQueryData @QueryProjection constructor( + val date: String, + val count: Long, + val totalCan: Int? +) { + fun toResponseItem(creatorNickname: String): GetCreatorChannelDonationSettlementItem { + val settlement = ChannelDonationSettlementCalculator.calculate(totalCan ?: 0) + + return GetCreatorChannelDonationSettlementItem( + date = date, + creator = creatorNickname, + count = count.toInt(), + totalCan = totalCan ?: 0, + krw = settlement.krw, + fee = settlement.fee, + settlementAmount = settlement.settlementAmount, + withholdingTax = settlement.withholdingTax, + depositAmount = settlement.depositAmount + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementResponse.kt new file mode 100644 index 00000000..81204c96 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/GetCreatorChannelDonationSettlementResponse.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +data class GetCreatorChannelDonationSettlementResponse( + val totalCount: Int, + val items: List +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt new file mode 100644 index 00000000..e1e34c6e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt @@ -0,0 +1,68 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.data.domain.PageRequest + +class AdminChannelDonationCalculateControllerTest { + private lateinit var service: AdminChannelDonationCalculateService + private lateinit var controller: AdminChannelDonationCalculateController + + @BeforeEach + fun setup() { + service = Mockito.mock(AdminChannelDonationCalculateService::class.java) + controller = AdminChannelDonationCalculateController(service) + } + + @Test + @DisplayName("관리자 컨트롤러는 날짜/페이지 파라미터를 서비스로 전달한다") + fun shouldForwardDateRangeAndPageableToService() { + val response = GetAdminChannelDonationSettlementResponse( + totalCount = 1, + items = listOf( + GetAdminChannelDonationSettlementItem( + date = "2026-02-26", + creator = "creator-a", + count = 2, + totalCan = 20, + krw = 2000, + fee = 132, + settlementAmount = 1588, + withholdingTax = 52, + depositAmount = 1536 + ) + ) + ) + + Mockito.`when`( + service.getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + offset = 15L, + limit = 15L + ) + ).thenReturn(response) + + val apiResponse = controller.getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + pageable = PageRequest.of(1, 15) + ) + + assertEquals(true, apiResponse.success) + assertEquals(1, apiResponse.data!!.totalCount) + assertEquals("creator-a", apiResponse.data!!.items[0].creator) + assertEquals(2, apiResponse.data!!.items[0].count) + assertEquals(20, apiResponse.data!!.items[0].totalCan) + + Mockito.verify(service).getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + offset = 15L, + limit = 15L + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepositoryTest.kt new file mode 100644 index 00000000..728e0a7c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepositoryTest.kt @@ -0,0 +1,126 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"]) +@Import(QueryDslConfig::class) +class AdminChannelDonationCalculateQueryRepositoryTest @Autowired constructor( + private val queryFactory: JPAQueryFactory, + private val memberRepository: MemberRepository, + private val useCanRepository: UseCanRepository, + private val useCanCalculateRepository: UseCanCalculateRepository, + private val entityManager: EntityManager +) { + private lateinit var repository: AdminChannelDonationCalculateQueryRepository + + @BeforeEach + fun setup() { + registerMysqlDateFunctions() + repository = AdminChannelDonationCalculateQueryRepository(queryFactory) + } + + @Test + @DisplayName("동일 후원의 분할 정산 레코드는 건수를 중복 집계하지 않는다") + fun shouldCountDistinctUseCanWhenDonationIsSplitAcrossCalculations() { + val creator = saveMember(nickname = "creator-admin", role = MemberRole.CREATOR) + val sender = saveMember(nickname = "sender-admin", role = MemberRole.USER) + val useCan = saveUseCan(member = sender, can = 50, rewardCan = 0) + + saveUseCanCalculate( + useCan = useCan, + recipientCreatorId = creator.id!!, + can = 20, + paymentGateway = PaymentGateway.PG + ) + saveUseCanCalculate( + useCan = useCan, + recipientCreatorId = creator.id!!, + can = 30, + paymentGateway = PaymentGateway.GOOGLE_IAP + ) + updateUseCanCreatedAt(useCan.id!!, LocalDateTime.of(2026, 2, 20, 12, 0, 0)) + entityManager.flush() + entityManager.clear() + + val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) + + val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate) + val items = repository.getChannelDonationByCreator(startDate, endDate, offset = 0, limit = 20) + + assertEquals(1, totalCount) + assertEquals(1, items.size) + assertEquals("2026-02-20", items[0].date) + assertEquals("creator-admin", items[0].creator) + assertEquals(1L, items[0].count) + assertEquals(50, items[0].totalCan) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + ) + } + + private fun saveUseCan(member: Member, can: Int, rewardCan: Int): UseCan { + val useCan = UseCan( + canUsage = CanUsage.CHANNEL_DONATION, + can = can, + rewardCan = rewardCan + ) + useCan.member = member + return useCanRepository.saveAndFlush(useCan) + } + + private fun saveUseCanCalculate(useCan: UseCan, recipientCreatorId: Long, can: Int, paymentGateway: PaymentGateway) { + val useCanCalculate = UseCanCalculate( + can = can, + paymentGateway = paymentGateway, + status = UseCanCalculateStatus.RECEIVED + ) + useCanCalculate.useCan = useCan + useCanCalculate.recipientCreatorId = recipientCreatorId + useCanCalculateRepository.saveAndFlush(useCanCalculate) + } + + private fun updateUseCanCreatedAt(useCanId: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update UseCan u set u.createdAt = :createdAt where u.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", useCanId) + .executeUpdate() + } + + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt new file mode 100644 index 00000000..6dc4e2b8 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt @@ -0,0 +1,72 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito + +class AdminChannelDonationCalculateServiceTest { + private lateinit var repository: AdminChannelDonationCalculateQueryRepository + private lateinit var service: AdminChannelDonationCalculateService + + @BeforeEach + fun setup() { + repository = Mockito.mock(AdminChannelDonationCalculateQueryRepository::class.java) + service = AdminChannelDonationCalculateService(repository) + } + + @Test + @DisplayName("관리자 정산 조회는 날짜 범위를 변환하고 크리에이터 그룹 응답을 반환한다") + fun shouldConvertDateRangeAndReturnCreatorGroupedItems() { + val queryData = GetAdminChannelDonationSettlementQueryData( + date = "2026-02-26", + creator = "creator-a", + count = 3L, + totalCan = 100 + ) + + Mockito.`when`( + repository.getChannelDonationByCreatorTotalCount( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59) + ) + ).thenReturn(1) + Mockito.`when`( + repository.getChannelDonationByCreator( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 0L, + 20L + ) + ).thenReturn(listOf(queryData)) + + val result = service.getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + offset = 0, + limit = 20 + ) + + assertEquals(1, result.totalCount) + assertEquals(1, result.items.size) + assertEquals("2026-02-26", result.items[0].date) + assertEquals("creator-a", result.items[0].creator) + assertEquals(3, result.items[0].count) + assertEquals(100, result.items[0].totalCan) + assertEquals(10_000, result.items[0].krw) + assertEquals(660, result.items[0].fee) + + Mockito.verify(repository).getChannelDonationByCreatorTotalCount( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59) + ) + Mockito.verify(repository).getChannelDonationByCreator( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 0L, + 20L + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculatorTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculatorTest.kt new file mode 100644 index 00000000..8ffaa24c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/calculate/channelDonation/ChannelDonationSettlementCalculatorTest.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.calculate.channelDonation + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class ChannelDonationSettlementCalculatorTest { + @Test + @DisplayName("정산 공식에 따라 금액을 계산한다") + fun shouldCalculateSettlementAmountsWithExpectedFormula() { + val result = ChannelDonationSettlementCalculator.calculate(totalCan = 100) + + assertEquals(10_000, result.krw) + assertEquals(660, result.fee) + assertEquals(7_939, result.settlementAmount) + assertEquals(262, result.withholdingTax) + assertEquals(7_677, result.depositAmount) + } + + @Test + @DisplayName("소수 계산은 단계별 반올림 규칙을 따른다") + fun shouldRoundHalfUpOnDecimalResults() { + val result = ChannelDonationSettlementCalculator.calculate(totalCan = 1) + + assertEquals(100, result.krw) + assertEquals(7, result.fee) + assertEquals(79, result.settlementAmount) + assertEquals(3, result.withholdingTax) + assertEquals(76, result.depositAmount) + } + + @Test + @DisplayName("총 캔 수가 0이면 모든 금액은 0이다") + fun shouldReturnZeroWhenTotalCanIsZero() { + val result = ChannelDonationSettlementCalculator.calculate(totalCan = 0) + + assertEquals(0, result.krw) + assertEquals(0, result.fee) + assertEquals(0, result.settlementAmount) + assertEquals(0, result.withholdingTax) + assertEquals(0, result.depositAmount) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateControllerTest.kt new file mode 100644 index 00000000..31022790 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateControllerTest.kt @@ -0,0 +1,104 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.data.domain.PageRequest + +class CreatorAdminChannelDonationCalculateControllerTest { + private lateinit var service: CreatorAdminChannelDonationCalculateService + private lateinit var controller: CreatorAdminChannelDonationCalculateController + + @BeforeEach + fun setup() { + service = Mockito.mock(CreatorAdminChannelDonationCalculateService::class.java) + controller = CreatorAdminChannelDonationCalculateController(service) + } + + @Test + @DisplayName("인증 사용자 정보가 없으면 예외를 던진다") + fun shouldThrowWhenMemberIsNull() { + val exception = assertThrows(SodaException::class.java) { + controller.getChannelDonation( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + member = null, + pageable = PageRequest.of(0, 10) + ) + } + + assertEquals("common.error.bad_credentials", exception.messageKey) + } + + @Test + @DisplayName("크리에이터 컨트롤러는 본인 ID/닉네임과 페이지 정보를 서비스로 전달한다") + fun shouldForwardMemberAndPageableToService() { + val member = createMember(7L) + val response = GetCreatorChannelDonationSettlementResponse( + totalCount = 1, + items = listOf( + GetCreatorChannelDonationSettlementItem( + date = "2026-02-26", + creator = "creator-self", + count = 4, + totalCan = 10, + krw = 1000, + fee = 66, + settlementAmount = 794, + withholdingTax = 26, + depositAmount = 768 + ) + ) + ) + + Mockito.`when`( + service.getChannelDonation( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + memberId = 7L, + creatorNickname = "creator", + offset = 10L, + limit = 5L + ) + ).thenReturn(response) + + val apiResponse = controller.getChannelDonation( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + member = member, + pageable = PageRequest.of(2, 5) + ) + + assertEquals(true, apiResponse.success) + assertEquals(1, apiResponse.data!!.totalCount) + assertEquals("creator-self", apiResponse.data!!.items[0].creator) + assertEquals(4, apiResponse.data!!.items[0].count) + assertEquals(10, apiResponse.data!!.items[0].totalCan) + + Mockito.verify(service).getChannelDonation( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + memberId = 7L, + creatorNickname = "creator", + offset = 10L, + limit = 5L + ) + } + + private fun createMember(id: Long): Member { + val member = Member( + email = "creator@test.com", + password = "password", + nickname = "creator", + role = MemberRole.CREATOR + ) + member.id = id + return member + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepositoryTest.kt new file mode 100644 index 00000000..5d06cec8 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepositoryTest.kt @@ -0,0 +1,125 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"]) +@Import(QueryDslConfig::class) +class CreatorAdminChannelDonationCalculateQueryRepositoryTest @Autowired constructor( + private val queryFactory: JPAQueryFactory, + private val memberRepository: MemberRepository, + private val useCanRepository: UseCanRepository, + private val useCanCalculateRepository: UseCanCalculateRepository, + private val entityManager: EntityManager +) { + private lateinit var repository: CreatorAdminChannelDonationCalculateQueryRepository + + @BeforeEach + fun setup() { + registerMysqlDateFunctions() + repository = CreatorAdminChannelDonationCalculateQueryRepository(queryFactory) + } + + @Test + @DisplayName("분할 정산 레코드가 있어도 크리에이터 정산 건수는 후원 단위로 집계한다") + fun shouldCountDistinctUseCanForCreatorWhenDonationIsSplitAcrossCalculations() { + val creator = saveMember(nickname = "creator-self", role = MemberRole.CREATOR) + val sender = saveMember(nickname = "sender-self", role = MemberRole.USER) + val useCan = saveUseCan(member = sender, can = 50, rewardCan = 0) + + saveUseCanCalculate( + useCan = useCan, + recipientCreatorId = creator.id!!, + can = 20, + paymentGateway = PaymentGateway.PG + ) + saveUseCanCalculate( + useCan = useCan, + recipientCreatorId = creator.id!!, + can = 30, + paymentGateway = PaymentGateway.GOOGLE_IAP + ) + updateUseCanCreatedAt(useCan.id!!, LocalDateTime.of(2026, 2, 20, 12, 0, 0)) + entityManager.flush() + entityManager.clear() + + val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) + + val totalCount = repository.getChannelDonationTotalCount(startDate, endDate, creator.id!!) + val items = repository.getChannelDonation(startDate, endDate, creator.id!!, offset = 0, limit = 20) + + assertEquals(1, totalCount) + assertEquals(1, items.size) + assertEquals("2026-02-20", items[0].date) + assertEquals(1L, items[0].count) + assertEquals(50, items[0].totalCan) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + ) + } + + private fun saveUseCan(member: Member, can: Int, rewardCan: Int): UseCan { + val useCan = UseCan( + canUsage = CanUsage.CHANNEL_DONATION, + can = can, + rewardCan = rewardCan + ) + useCan.member = member + return useCanRepository.saveAndFlush(useCan) + } + + private fun saveUseCanCalculate(useCan: UseCan, recipientCreatorId: Long, can: Int, paymentGateway: PaymentGateway) { + val useCanCalculate = UseCanCalculate( + can = can, + paymentGateway = paymentGateway, + status = UseCanCalculateStatus.RECEIVED + ) + useCanCalculate.useCan = useCan + useCanCalculate.recipientCreatorId = recipientCreatorId + useCanCalculateRepository.saveAndFlush(useCanCalculate) + } + + private fun updateUseCanCreatedAt(useCanId: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update UseCan u set u.createdAt = :createdAt where u.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", useCanId) + .executeUpdate() + } + + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateServiceTest.kt new file mode 100644 index 00000000..05343e82 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateServiceTest.kt @@ -0,0 +1,75 @@ +package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation + +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito + +class CreatorAdminChannelDonationCalculateServiceTest { + private lateinit var repository: CreatorAdminChannelDonationCalculateQueryRepository + private lateinit var service: CreatorAdminChannelDonationCalculateService + + @BeforeEach + fun setup() { + repository = Mockito.mock(CreatorAdminChannelDonationCalculateQueryRepository::class.java) + service = CreatorAdminChannelDonationCalculateService(repository) + } + + @Test + @DisplayName("크리에이터 관리자 정산 조회는 본인 범위와 날짜 범위를 적용한다") + fun shouldApplyMemberScopeAndDateRange() { + val queryData = GetCreatorChannelDonationSettlementQueryData( + date = "2026-02-26", + count = 2L, + totalCan = 50 + ) + + Mockito.`when`( + repository.getChannelDonationTotalCount( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 7L + ) + ).thenReturn(1) + Mockito.`when`( + repository.getChannelDonation( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 7L, + 0L, + 20L + ) + ).thenReturn(listOf(queryData)) + + val result = service.getChannelDonation( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + memberId = 7L, + creatorNickname = "creator-self", + offset = 0, + limit = 20 + ) + + assertEquals(1, result.totalCount) + assertEquals(1, result.items.size) + assertEquals("creator-self", result.items[0].creator) + assertEquals(2, result.items[0].count) + assertEquals(50, result.items[0].totalCan) + assertEquals(5_000, result.items[0].krw) + + Mockito.verify(repository).getChannelDonationTotalCount( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 7L + ) + Mockito.verify(repository).getChannelDonation( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 7L, + 0L, + 20L + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MySqlFunctionDialect.kt b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MySqlFunctionDialect.kt new file mode 100644 index 00000000..502511e9 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MySqlFunctionDialect.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.support + +import org.hibernate.dialect.H2Dialect +import org.hibernate.dialect.function.StandardSQLFunction +import org.hibernate.type.StandardBasicTypes + +class H2MySqlFunctionDialect : H2Dialect() { + init { + registerFunction("date_format", StandardSQLFunction("DATE_FORMAT", StandardBasicTypes.STRING)) + registerFunction("DATE_FORMAT", StandardSQLFunction("DATE_FORMAT", StandardBasicTypes.STRING)) + registerFunction("convert_tz", StandardSQLFunction("CONVERT_TZ", StandardBasicTypes.TIMESTAMP)) + registerFunction("CONVERT_TZ", StandardSQLFunction("CONVERT_TZ", StandardBasicTypes.TIMESTAMP)) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt new file mode 100644 index 00000000..eb0a446d --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.support + +import java.sql.Timestamp +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +class H2MysqlDateFunctions { + companion object { + @JvmStatic + fun convertTz(value: Timestamp?, fromTz: String?, toTz: String?): Timestamp? { + if (value == null || fromTz == null || toTz == null) { + return value + } + + val fromZoneId = ZoneId.of(fromTz) + val toZoneId = ZoneId.of(toTz) + val converted = value.toLocalDateTime().atZone(fromZoneId).withZoneSameInstant(toZoneId).toLocalDateTime() + + return Timestamp.valueOf(converted) + } + + @JvmStatic + fun dateFormat(value: Timestamp?, pattern: String?): String? { + if (value == null || pattern == null) { + return null + } + + val javaPattern = pattern + .replace("%Y", "yyyy") + .replace("%m", "MM") + .replace("%d", "dd") + + return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern)) + } + } +}