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

View File

@@ -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<GetAdminChannelDonationSettlementQueryData> {
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<LocalDateTime>): 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"
)
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
data class GetAdminChannelDonationSettlementResponse(
val totalCount: Int,
val items: List<GetAdminChannelDonationSettlementItem>
)

View File

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

View File

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

View File

@@ -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<GetCreatorChannelDonationSettlementQueryData> {
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<LocalDateTime>): 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"
)
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation
data class GetCreatorChannelDonationSettlementResponse(
val totalCount: Int,
val items: List<GetCreatorChannelDonationSettlementItem>
)