feat(channel-donation-calculate): 채널 후원 정산 조회 기능을 추가한다

This commit is contained in:
2026-02-26 18:57:02 +09:00
parent dd9cd788ca
commit 19d3544c72
24 changed files with 1346 additions and 0 deletions

View File

@@ -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
)
}
}

View File

@@ -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()
}
}

View File

@@ -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
)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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
)
}
}

View File

@@ -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))
}
}

View File

@@ -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))
}
}
}