feat(admin-charge): 관리자 캔 환불 API로 미사용 7일 이내 환불을 처리한다
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
data class AdminChargeRefundRequest(
|
||||
val chargeId: Long
|
||||
)
|
||||
@@ -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,]*")
|
||||
}
|
||||
}
|
||||
@@ -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日が経過しているため返金できません。"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user