From 21d26b76f4bd8fa4fa73e3fc2736bf878fd0e2f3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 5 Mar 2026 17:05:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin-charge):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=BA=94=20=ED=99=98=EB=B6=88=20API=EB=A1=9C=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=207=EC=9D=BC=20=EC=9D=B4=EB=82=B4?= =?UTF-8?q?=20=ED=99=98=EB=B6=88=EC=9D=84=20=EC=B2=98=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260305_캔환불API생성.md | 38 +++ .../charge/AdminChargeRefundController.kt | 16 ++ .../admin/charge/AdminChargeRefundRequest.kt | 5 + .../admin/charge/AdminChargeRefundService.kt | 106 ++++++++ .../sodalive/i18n/SodaMessageSource.kt | 10 + .../charge/AdminChargeRefundServiceTest.kt | 255 ++++++++++++++++++ 6 files changed, 430 insertions(+) create mode 100644 docs/20260305_캔환불API생성.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundServiceTest.kt diff --git a/docs/20260305_캔환불API생성.md b/docs/20260305_캔환불API생성.md new file mode 100644 index 00000000..63171609 --- /dev/null +++ b/docs/20260305_캔환불API생성.md @@ -0,0 +1,38 @@ +- [x] 기존 charge/payment/member 및 admin API 패턴 확인 +- [x] `kr.co.vividnext.sodalive.admin.charge` 패키지에 캔 환불 API 생성 +- [x] 환불 조건 검증 구현 (미사용, 7일 이내) +- [x] ChargeEntity/PaymentEntity/MemberEntity 환불 반영 로직 구현 +- [x] 캔 환불 API 테스트 코드 작성 +- [x] 검증 실행 및 결과 기록 + +## 환불 조건 상세 +- 환불 가능 충전내역 조건: `charge.status == CHARGE` 그리고 `payment.status == COMPLETE` +- 이미 사용한 캔 판정 조건: `charge.title`에서 숫자를 추출해 현재 `chargeCan/rewardCan`과 비교 + - 예시1) `100 캔 + 50 캔` -> `chargeCan = 100`, `rewardCan = 50` + - 예시2) `5,000 캔 + 500 캔` -> `chargeCan = 5000`, `rewardCan = 500` + - 예시3) `500캔` -> `chargeCan = 500` + - 예시4) `4,000 캔` -> `chargeCan = 4000` + +## 검증 기록 + +### 1차 구현 +- 무엇을: 관리자 캔 환불 API(`POST /admin/charge/refund`)와 환불 서비스/요청 DTO, i18n 메시지, 단위 테스트를 추가했다. +- 왜: 사용하지 않은 캔만 7일 이내 환불 가능하도록 하고, 환불 시 Charge/Payment/Member 상태를 요구사항대로 갱신하기 위해. +- 어떻게: + - `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공 + - `./gradlew build` 실행 → 성공 (ktlint/check/test/build 포함) + - LSP 진단 시도(`lsp_diagnostics`) → Kotlin LSP 미설정으로 불가, 대신 Gradle 컴파일/ktlint/test/build로 검증 + +### 2차 수정 +- 무엇을: `AdminChargeRefundServiceTest`에 한글 `@DisplayName`을 추가하고, 각 테스트 문단에 given/when/then 역할 주석을 보강했다. +- 왜: 테스트 의도를 한눈에 파악하고, 문단별 책임을 명확히 하기 위해. +- 어떻게: + - `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공 + - `./gradlew ktlintTestSourceSetCheck` 실행 → 성공 + +### 3차 수정 +- 무엇을: 이미 사용한 캔 판정을 `charge.title` 숫자 파싱 비교 방식으로 변경하고, 단일 숫자/콤마 포함 제목 테스트 케이스를 추가했다. +- 왜: 환불 조건을 충전 제목 기반 비교 규칙(단일/복수 숫자, 콤마 포함)으로 명확하게 적용하기 위해. +- 어떻게: + - `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공 + - `./gradlew build` 실행 → 성공 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundController.kt new file mode 100644 index 00000000..32eb7e29 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundController.kt @@ -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)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundRequest.kt new file mode 100644 index 00000000..5e8fb8f3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundRequest.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.admin.charge + +data class AdminChargeRefundRequest( + val chargeId: Long +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundService.kt new file mode 100644 index 00000000..f42346b2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundService.kt @@ -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 { + 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,]*") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 68e4aad0..0637b66d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -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日が経過しているため返金できません。" ) ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundServiceTest.kt new file mode 100644 index 00000000..3de85cf7 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundServiceTest.kt @@ -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) + } +}