diff --git a/src/main/java/com/samhap/kokomen/token/domain/TokenPurchase.java b/src/main/java/com/samhap/kokomen/token/domain/TokenPurchase.java index c3013eaf..e3354959 100644 --- a/src/main/java/com/samhap/kokomen/token/domain/TokenPurchase.java +++ b/src/main/java/com/samhap/kokomen/token/domain/TokenPurchase.java @@ -26,6 +26,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class TokenPurchase extends BaseEntity { + private static final long REFUND_EXPIRY_YEARS = 1; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -176,7 +178,11 @@ public boolean isNotRefundable() { } public boolean isRefundable() { - return state == TokenPurchaseState.REFUNDABLE && purchaseCount.equals(remainingCount); + return state == TokenPurchaseState.REFUNDABLE && purchaseCount.equals(remainingCount) && !isRefundExpired(); + } + + public boolean isRefundExpired() { + return getCreatedAt().plusYears(REFUND_EXPIRY_YEARS).isBefore(LocalDateTime.now()); } public boolean isNotOwnedBy(Long memberId) { diff --git a/src/main/java/com/samhap/kokomen/token/domain/TokenPurchaseState.java b/src/main/java/com/samhap/kokomen/token/domain/TokenPurchaseState.java index f1a9ac3d..5b4d5945 100644 --- a/src/main/java/com/samhap/kokomen/token/domain/TokenPurchaseState.java +++ b/src/main/java/com/samhap/kokomen/token/domain/TokenPurchaseState.java @@ -6,6 +6,7 @@ @Getter public enum TokenPurchaseState { REFUNDABLE("환불 가능"), + REFUND_EXPIRED("환불 기한 만료"), USABLE("사용 중"), EXHAUSTED("사용 완료"), REFUNDED("환불 완료"); diff --git a/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseResponse.java b/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseResponse.java index 7cff10e4..7f0aca33 100644 --- a/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseResponse.java +++ b/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseResponse.java @@ -2,6 +2,7 @@ import com.samhap.kokomen.product.domain.TokenProduct; import com.samhap.kokomen.token.domain.TokenPurchase; +import com.samhap.kokomen.token.domain.TokenPurchaseState; public record TokenPurchaseResponse( Long id, @@ -23,9 +24,16 @@ public static TokenPurchaseResponse from(TokenPurchase tokenPurchase) { tokenPurchase.getProductName(), tokenPurchase.getPurchaseCount(), tokenPurchase.getRemainingCount(), - tokenPurchase.getState().getDisplayMessage(), + resolveDisplayState(tokenPurchase), tokenPurchase.getPaymentMethod(), tokenPurchase.getEasyPayProvider() ); } + + private static String resolveDisplayState(TokenPurchase tokenPurchase) { + if (tokenPurchase.getState() == TokenPurchaseState.REFUNDABLE && tokenPurchase.isRefundExpired()) { + return TokenPurchaseState.REFUND_EXPIRED.getDisplayMessage(); + } + return tokenPurchase.getState().getDisplayMessage(); + } } diff --git a/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java b/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java index 1ba4ee53..8c5a9047 100644 --- a/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java +++ b/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java @@ -146,6 +146,10 @@ public void refundTokens(Long memberId, Long tokenPurchaseId, TokenRefundRequest throw new BadRequestException("본인의 토큰 구매 내역만 환불할 수 있습니다."); } + if (tokenPurchase.isRefundExpired()) { + throw new BadRequestException("구매일로부터 1년이 경과하여 환불이 불가합니다."); + } + if (tokenPurchase.isNotRefundable()) { throw new BadRequestException("환불 불가능한 상태입니다. 환불 가능한 토큰은 사용하지 않은 상태여야 합니다."); } diff --git a/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java b/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java index cf07d565..fc3e1ab1 100644 --- a/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java +++ b/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java @@ -54,6 +54,8 @@ class TokenControllerTest extends BaseControllerTest { private TokenService tokenService; @Autowired private TokenPurchaseRepository tokenPurchaseRepository; + @Autowired + private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; @Test void 토큰_구매_DTO_검증_실패() throws Exception { @@ -628,6 +630,42 @@ class TokenControllerTest extends BaseControllerTest { )); } + @Test + void 구매일로부터_1년_경과한_토큰_환불_실패() throws Exception { + // given + Member member = memberRepository.save(MemberFixtureBuilder.builder().build()); + tokenService.createTokensForNewMember(member.getId()); + + TokenPurchase tokenPurchase = tokenPurchaseRepository.save( + TokenPurchaseFixtureBuilder.builder() + .memberId(member.getId()) + .count(10) + .remainingCount(10) + .state(TokenPurchaseState.REFUNDABLE) + .build() + ); + + // created_at을 1년 전으로 변경 (native SQL로 @CreatedDate 필드 직접 수정) + jdbcTemplate.update( + "UPDATE token_purchase SET created_at = ? WHERE id = ?", + java.time.LocalDateTime.now().minusYears(1).minusDays(1), + tokenPurchase.getId() + ); + + MockHttpSession session = new MockHttpSession(); + session.setAttribute("MEMBER_ID", member.getId()); + + TokenRefundRequest request = new TokenRefundRequest(RefundReasonCode.CHANGE_OF_MIND, null); + + // when & then + mockMvc.perform(patch("/api/v1/token-purchases/{tokenPurchaseId}/refund", tokenPurchase.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header("Cookie", "JSESSIONID=" + session.getId()) + .session(session)) + .andExpect(status().isBadRequest()); + } + @Test void 타인의_토큰_환불_실패() throws Exception { // given diff --git a/src/test/java/com/samhap/kokomen/token/domain/TokenPurchaseTest.java b/src/test/java/com/samhap/kokomen/token/domain/TokenPurchaseTest.java new file mode 100644 index 00000000..c6d0bf5f --- /dev/null +++ b/src/test/java/com/samhap/kokomen/token/domain/TokenPurchaseTest.java @@ -0,0 +1,66 @@ +package com.samhap.kokomen.token.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.samhap.kokomen.global.fixture.token.TokenPurchaseFixtureBuilder; +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; + +class TokenPurchaseTest { + + @Test + void 구매일로부터_1년_이내이면_환불_가능하다() { + // given + TokenPurchase tokenPurchase = TokenPurchaseFixtureBuilder.builder() + .state(TokenPurchaseState.REFUNDABLE) + .count(10) + .remainingCount(10) + .build(); + setCreatedAt(tokenPurchase, LocalDateTime.now().minusMonths(6)); + + // when & then + assertThat(tokenPurchase.isRefundable()).isTrue(); + assertThat(tokenPurchase.isRefundExpired()).isFalse(); + } + + @Test + void 구매일로부터_1년이_경과하면_환불_불가능하다() { + // given + TokenPurchase tokenPurchase = TokenPurchaseFixtureBuilder.builder() + .state(TokenPurchaseState.REFUNDABLE) + .count(10) + .remainingCount(10) + .build(); + setCreatedAt(tokenPurchase, LocalDateTime.now().minusYears(1).minusDays(1)); + + // when & then + assertThat(tokenPurchase.isRefundable()).isFalse(); + assertThat(tokenPurchase.isRefundExpired()).isTrue(); + } + + @Test + void 구매일로부터_정확히_1년이면_환불_가능하다() { + // given + TokenPurchase tokenPurchase = TokenPurchaseFixtureBuilder.builder() + .state(TokenPurchaseState.REFUNDABLE) + .count(10) + .remainingCount(10) + .build(); + setCreatedAt(tokenPurchase, LocalDateTime.now().minusYears(1).plusMinutes(1)); + + // when & then + assertThat(tokenPurchase.isRefundable()).isTrue(); + assertThat(tokenPurchase.isRefundExpired()).isFalse(); + } + + private void setCreatedAt(TokenPurchase tokenPurchase, LocalDateTime createdAt) { + try { + Field field = tokenPurchase.getClass().getSuperclass().getDeclaredField("createdAt"); + field.setAccessible(true); + field.set(tokenPurchase, createdAt); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException("createdAt 필드 설정 실패", e); + } + } +}