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日が経過しているため返金できません。"
)
)

View File

@@ -0,0 +1,255 @@
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.Payment
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.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
import java.util.Optional
@DisplayName("관리자 캔 환불 서비스 테스트")
class AdminChargeRefundServiceTest {
private lateinit var chargeRepository: ChargeRepository
private lateinit var messageSource: SodaMessageSource
private lateinit var langContext: LangContext
private lateinit var service: AdminChargeRefundService
@BeforeEach
fun setup() {
// given: 테스트 대상 의존성을 목 객체로 준비한다.
chargeRepository = Mockito.mock(ChargeRepository::class.java)
messageSource = Mockito.mock(SodaMessageSource::class.java)
langContext = Mockito.mock(LangContext::class.java)
// given: 메시지 포맷 테스트를 위해 기본 언어를 한국어로 고정한다.
Mockito.`when`(langContext.lang).thenReturn(Lang.KO)
// given: 환불 서비스 인스턴스를 생성한다.
service = AdminChargeRefundService(
chargeRepository = chargeRepository,
messageSource = messageSource,
langContext = langContext
)
}
@Test
@DisplayName("사용하지 않은 구글 인앱 캔을 7일 이내에 환불한다")
fun shouldRefundGoogleIapCanWhenUnusedWithinSevenDays() {
// given: 구글 인앱 충전 캔을 보유한 회원을 준비한다.
val member = Member(password = "password", nickname = "tester")
member.googleChargeCan = 50
member.googleRewardCan = 10
// given: 7일 이내 생성된 충전/결제 데이터를 준비한다.
val charge = Charge(50, 10, status = ChargeStatus.CHARGE)
charge.id = 1L
charge.title = "50 캔 + 10 캔"
charge.createdAt = LocalDateTime.now().minusDays(3)
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.GOOGLE_IAP)
charge.payment = payment
// given: 조회 시 환불 대상 충전을 반환하도록 설정한다.
Mockito.`when`(chargeRepository.findById(1L)).thenReturn(Optional.of(charge))
// when: 관리자 환불을 실행한다.
service.refund(AdminChargeRefundRequest(chargeId = 1L))
// then: 충전/결제/회원 캔 상태가 환불 기준으로 변경된다.
assertEquals(0, charge.chargeCan)
assertEquals(0, charge.rewardCan)
assertEquals(ChargeStatus.CANCEL, charge.status)
assertEquals(PaymentStatus.RETURN, payment.status)
assertEquals(0, member.googleChargeCan)
assertEquals(0, member.googleRewardCan)
}
@Test
@DisplayName("사용하지 않은 PG 캔을 7일 이내에 환불한다 (콤마 포함 제목 파싱)")
fun shouldRefundPgCanWhenUnusedWithinSevenDays() {
// given: PG 충전 캔을 보유한 회원을 준비한다.
val member = Member(password = "password", nickname = "tester")
member.pgChargeCan = 5000
member.pgRewardCan = 500
// given: 콤마가 포함된 제목과 7일 이내 생성된 충전/결제 데이터를 준비한다.
val charge = Charge(5000, 500, status = ChargeStatus.CHARGE)
charge.id = 2L
charge.title = "5,000 캔 + 500 캔"
charge.createdAt = LocalDateTime.now().minusDays(1)
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
charge.payment = payment
// given: 조회 시 환불 대상 충전을 반환하도록 설정한다.
Mockito.`when`(chargeRepository.findById(2L)).thenReturn(Optional.of(charge))
// when: 관리자 환불을 실행한다.
service.refund(AdminChargeRefundRequest(chargeId = 2L))
// then: 충전/결제/회원 캔 상태가 환불 기준으로 변경된다.
assertEquals(0, charge.chargeCan)
assertEquals(0, charge.rewardCan)
assertEquals(ChargeStatus.CANCEL, charge.status)
assertEquals(PaymentStatus.RETURN, payment.status)
assertEquals(0, member.pgChargeCan)
assertEquals(0, member.pgRewardCan)
}
@Test
@DisplayName("제목이 단일 숫자(500캔)인 경우 chargeCan만 비교해 환불한다")
fun shouldRefundWhenTitleContainsOnlyChargeCanWithoutSpace() {
// given: 단일 숫자 제목과 일치하는 PG 충전 데이터를 준비한다.
val member = Member(password = "password", nickname = "tester")
member.pgChargeCan = 500
member.pgRewardCan = 0
val charge = Charge(500, 0, status = ChargeStatus.CHARGE)
charge.id = 21L
charge.title = "500캔"
charge.createdAt = LocalDateTime.now().minusDays(2)
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
charge.payment = payment
// given: 조회 시 환불 대상 충전을 반환하도록 설정한다.
Mockito.`when`(chargeRepository.findById(21L)).thenReturn(Optional.of(charge))
// when: 관리자 환불을 실행한다.
service.refund(AdminChargeRefundRequest(chargeId = 21L))
// then: 단일 숫자 제목을 chargeCan으로 파싱해 정상 환불된다.
assertEquals(0, charge.chargeCan)
assertEquals(0, charge.rewardCan)
assertEquals(ChargeStatus.CANCEL, charge.status)
assertEquals(PaymentStatus.RETURN, payment.status)
assertEquals(0, member.pgChargeCan)
assertEquals(0, member.pgRewardCan)
}
@Test
@DisplayName("제목이 단일 숫자(4,000 캔)인 경우 콤마를 제거하고 chargeCan을 비교한다")
fun shouldRefundWhenTitleContainsOnlyChargeCanWithComma() {
// given: 콤마가 포함된 단일 숫자 제목과 일치하는 PG 충전 데이터를 준비한다.
val member = Member(password = "password", nickname = "tester")
member.pgChargeCan = 4000
member.pgRewardCan = 0
val charge = Charge(4000, 0, status = ChargeStatus.CHARGE)
charge.id = 22L
charge.title = "4,000 캔"
charge.createdAt = LocalDateTime.now().minusDays(2)
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
charge.payment = payment
// given: 조회 시 환불 대상 충전을 반환하도록 설정한다.
Mockito.`when`(chargeRepository.findById(22L)).thenReturn(Optional.of(charge))
// when: 관리자 환불을 실행한다.
service.refund(AdminChargeRefundRequest(chargeId = 22L))
// then: 콤마를 제거한 값(4000)으로 비교해 정상 환불된다.
assertEquals(0, charge.chargeCan)
assertEquals(0, charge.rewardCan)
assertEquals(ChargeStatus.CANCEL, charge.status)
assertEquals(PaymentStatus.RETURN, payment.status)
assertEquals(0, member.pgChargeCan)
assertEquals(0, member.pgRewardCan)
}
@Test
@DisplayName("이미 사용한 캔은 환불할 수 없다")
fun shouldThrowWhenCanWasUsed() {
// given: 제목 숫자(원본) 대비 현재 charge 수량이 감소한 데이터를 준비한다.
val member = Member(password = "password", nickname = "tester")
member.pgChargeCan = 80
member.pgRewardCan = 50
val charge = Charge(80, 50, status = ChargeStatus.CHARGE)
charge.id = 3L
charge.title = "100 캔 + 50 캔"
charge.createdAt = LocalDateTime.now().minusDays(2)
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
charge.payment = payment
// given: 조회 시 사용 이력이 있는 충전을 반환하도록 설정한다.
Mockito.`when`(chargeRepository.findById(3L)).thenReturn(Optional.of(charge))
// when: 관리자 환불을 실행하고 예외를 수집한다.
val exception = assertThrows(SodaException::class.java) {
service.refund(AdminChargeRefundRequest(chargeId = 3L))
}
// then: 사용 캔 환불 불가 메시지 키가 반환된다.
assertEquals("can.payment.refund.used_not_allowed", exception.messageKey)
}
@Test
@DisplayName("충전 후 7일이 지난 캔은 환불할 수 없다")
fun shouldThrowWhenChargedMoreThanSevenDaysAgo() {
// given: 7일을 초과한 충전 데이터를 준비한다.
val member = Member(password = "password", nickname = "tester")
member.pgChargeCan = 100
member.pgRewardCan = 20
val charge = Charge(100, 20, status = ChargeStatus.CHARGE)
charge.id = 4L
charge.title = "100 캔 + 20 캔"
charge.createdAt = LocalDateTime.now().minusDays(10)
charge.member = member
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
charge.payment = payment
// given: 조회 및 메시지 템플릿 반환 동작을 설정한다.
Mockito.`when`(chargeRepository.findById(4L)).thenReturn(Optional.of(charge))
Mockito.`when`(
messageSource.getMessage("can.payment.refund.days_exceeded", Lang.KO)
).thenReturn("충천 후 %s일이 지나서 환불할 수 없습니다.")
// when: 관리자 환불을 실행하고 예외를 수집한다.
val exception = assertThrows(SodaException::class.java) {
service.refund(AdminChargeRefundRequest(chargeId = 4L))
}
// then: 일수 포함 환불 불가 메시지가 반환된다.
assertTrue(exception.message!!.startsWith("충천 후 "))
assertTrue(exception.message!!.contains("환불할 수 없습니다."))
}
@Test
@DisplayName("존재하지 않는 충전 건은 환불할 수 없다")
fun shouldThrowWhenChargeNotFound() {
// given: 환불 대상 충전이 존재하지 않도록 설정한다.
Mockito.`when`(chargeRepository.findById(999L)).thenReturn(Optional.empty())
// when: 관리자 환불을 실행하고 예외를 수집한다.
val exception = assertThrows(SodaException::class.java) {
service.refund(AdminChargeRefundRequest(chargeId = 999L))
}
// then: 잘못된 요청 메시지 키가 반환된다.
assertEquals("common.error.invalid_request", exception.messageKey)
}
}