feat(admin-charge): 관리자 캔 환불 API로 미사용 7일 이내 환불을 처리한다

This commit is contained in:
2026-03-05 17:05:05 +09:00
parent 12f3a76c57
commit 21d26b76f4
6 changed files with 430 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.admin.charge
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@PreAuthorize("hasRole('ADMIN')")
@RequestMapping("/admin/charge")
class AdminChargeRefundController(private val service: AdminChargeRefundService) {
@PostMapping("/refund")
fun refund(@RequestBody request: AdminChargeRefundRequest) = ApiResponse.ok(service.refund(request))
}

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.admin.charge
data class AdminChargeRefundRequest(
val chargeId: Long
)

View File

@@ -0,0 +1,106 @@
package kr.co.vividnext.sodalive.admin.charge
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
@Service
class AdminChargeRefundService(
private val chargeRepository: ChargeRepository,
private val messageSource: SodaMessageSource,
private val langContext: LangContext
) {
@Transactional
fun refund(request: AdminChargeRefundRequest) {
val charge = chargeRepository.findByIdOrNull(request.chargeId)
?: throw SodaException(messageKey = "common.error.invalid_request")
val payment = charge.payment
?: throw SodaException(messageKey = "common.error.invalid_request")
val member = charge.member
?: throw SodaException(messageKey = "common.error.invalid_request")
if (charge.status != ChargeStatus.CHARGE || payment.status != PaymentStatus.COMPLETE) {
throw SodaException(messageKey = "can.payment.refund.invalid_request")
}
validateRefundDate(charge)
validateUnusedCan(charge)
deductMemberCan(member, payment.paymentGateway, charge.chargeCan, charge.rewardCan)
charge.chargeCan = 0
charge.rewardCan = 0
charge.status = ChargeStatus.CANCEL
payment.status = PaymentStatus.RETURN
}
private fun validateUnusedCan(charge: Charge) {
val title = charge.title ?: throw SodaException(messageKey = "common.error.invalid_request")
val (originalChargeCan, originalRewardCan) = extractCanFromTitle(title)
if (charge.chargeCan != originalChargeCan || charge.rewardCan != originalRewardCan) {
throw SodaException(messageKey = "can.payment.refund.used_not_allowed")
}
}
private fun extractCanFromTitle(title: String): Pair<Int, Int> {
val parsedNumbers = TITLE_CAN_REGEX
.findAll(title)
.map { it.value.replace(",", "").toIntOrNull() }
.toList()
if (parsedNumbers.isEmpty() || parsedNumbers.first() == null) {
throw SodaException(messageKey = "common.error.invalid_request")
}
val chargeCanFromTitle = parsedNumbers.first()!!
val rewardCanFromTitle = parsedNumbers.getOrNull(1) ?: 0
return Pair(chargeCanFromTitle, rewardCanFromTitle)
}
private fun validateRefundDate(charge: Charge) {
val chargedAt = charge.createdAt
?: throw SodaException(messageKey = "common.error.invalid_request")
val now = LocalDateTime.now()
if (now.isAfter(chargedAt.plusDays(7))) {
val passedDays = ChronoUnit.DAYS.between(chargedAt.toLocalDate(), now.toLocalDate())
val messageTemplate = messageSource.getMessage("can.payment.refund.days_exceeded", langContext.lang)
?: "충천 후 %s일이 지나서 환불할 수 없습니다."
throw SodaException(message = String.format(messageTemplate, passedDays))
}
}
private fun deductMemberCan(member: Member, paymentGateway: PaymentGateway, chargeCan: Int, rewardCan: Int) {
when (paymentGateway) {
PaymentGateway.GOOGLE_IAP -> {
member.googleChargeCan -= chargeCan
member.googleRewardCan -= rewardCan
}
PaymentGateway.APPLE_IAP -> {
member.appleChargeCan -= chargeCan
member.appleRewardCan -= rewardCan
}
else -> {
member.pgChargeCan -= chargeCan
member.pgRewardCan -= rewardCan
}
}
}
companion object {
private val TITLE_CAN_REGEX = Regex("\\d[\\d,]*")
}
}

View File

@@ -614,6 +614,16 @@ class SodaMessageSource {
Lang.KO to "%s 캔이 부족합니다. 충전 후 이용해 주세요.",
Lang.EN to "You are short of %s cans. Please recharge and try again.",
Lang.JA to "%sCANが不足しています。チャージしてからご利用ください。"
),
"can.payment.refund.used_not_allowed" to mapOf(
Lang.KO to "사용한 캔은 환불할 수 없습니다.",
Lang.EN to "Used cans cannot be refunded.",
Lang.JA to "使用したCANは返金できません。"
),
"can.payment.refund.days_exceeded" to mapOf(
Lang.KO to "충천 후 %s일이 지나서 환불할 수 없습니다.",
Lang.EN to "Refund is not available because %s days have passed since charging.",
Lang.JA to "チャージ後%s日が経過しているため返金できません。"
)
)