diff --git a/.github/workflows/cd-api-dev.yml b/.github/workflows/cd-api-dev.yml index f3be1527..859fc65e 100644 --- a/.github/workflows/cd-api-dev.yml +++ b/.github/workflows/cd-api-dev.yml @@ -97,6 +97,7 @@ jobs: SUPERTONE_API_TOKEN: ${{ secrets.SUPERTONE_API_TOKEN }} GOOGLE_CLIENT_ID_DEV: ${{ secrets.GOOGLE_CLIENT_ID_DEV }} GOOGLE_CLIENT_SECRET_DEV: ${{ secrets.GOOGLE_CLIENT_SECRET_DEV }} + WIDGET_SECRET_KEY_DEV: ${{ secrets.WIDGET_SECRET_KEY_DEV }} run: | export HOSTNAME=$(hostname) cd kokomen-backend/docker/dev diff --git a/.github/workflows/cd-api-load-test.yml b/.github/workflows/cd-api-load-test.yml index 82303f31..2bebf716 100644 --- a/.github/workflows/cd-api-load-test.yml +++ b/.github/workflows/cd-api-load-test.yml @@ -84,6 +84,7 @@ jobs: SUPERTONE_API_TOKEN: ${{ secrets.SUPERTONE_API_TOKEN }} GOOGLE_CLIENT_ID_PROD: ${{ secrets.GOOGLE_CLIENT_ID_PROD }} GOOGLE_CLIENT_SECRET_PROD: ${{ secrets.GOOGLE_CLIENT_SECRET_PROD }} + WIDGET_SECRET_KEY_PROD: ${{ secrets.WIDGET_SECRET_KEY_PROD }} MYSQL_HOST: ${{ secrets.MYSQL_HOST }} run: | export HOSTNAME=$(hostname) diff --git a/.github/workflows/cd-api-prod.yml b/.github/workflows/cd-api-prod.yml index fc7ef076..2c2b6b24 100644 --- a/.github/workflows/cd-api-prod.yml +++ b/.github/workflows/cd-api-prod.yml @@ -84,7 +84,7 @@ jobs: SUPERTONE_API_TOKEN: ${{ secrets.SUPERTONE_API_TOKEN }} GOOGLE_CLIENT_ID_PROD: ${{ secrets.GOOGLE_CLIENT_ID_PROD }} GOOGLE_CLIENT_SECRET_PROD: ${{ secrets.GOOGLE_CLIENT_SECRET_PROD }} - PAYMENT_BASE_URL_PROD: ${{ secrets.PAYMENT_BASE_URL_PROD }} + WIDGET_SECRET_KEY_PROD: ${{ secrets.WIDGET_SECRET_KEY_PROD }} run: | export HOSTNAME=$(hostname) cd kokomen-backend/docker/prod/api diff --git a/docker/dev/docker-compose-dev.yml b/docker/dev/docker-compose-dev.yml index 6d7afbc6..f3626479 100644 --- a/docker/dev/docker-compose-dev.yml +++ b/docker/dev/docker-compose-dev.yml @@ -23,6 +23,7 @@ services: SUPERTONE_API_TOKEN: ${SUPERTONE_API_TOKEN} GOOGLE_CLIENT_ID_DEV: ${GOOGLE_CLIENT_ID_DEV} GOOGLE_CLIENT_SECRET_DEV: ${GOOGLE_CLIENT_SECRET_DEV} + WIDGET_SECRET_KEY_DEV: ${WIDGET_SECRET_KEY_DEV} networks: - dev-kokomen-net diff --git a/docker/load-test/api/docker-compose-prod.yml b/docker/load-test/api/docker-compose-prod.yml index b7781ce2..482d1ffc 100644 --- a/docker/load-test/api/docker-compose-prod.yml +++ b/docker/load-test/api/docker-compose-prod.yml @@ -24,6 +24,7 @@ services: SUPERTONE_API_TOKEN: ${SUPERTONE_API_TOKEN} GOOGLE_CLIENT_ID_PROD: ${GOOGLE_CLIENT_ID_PROD} GOOGLE_CLIENT_SECRET_PROD: ${GOOGLE_CLIENT_SECRET_PROD} + WIDGET_SECRET_KEY_PROD: ${WIDGET_SECRET_KEY_PROD} node: image: prom/node-exporter diff --git a/docker/prod/api/docker-compose-prod.yml b/docker/prod/api/docker-compose-prod.yml index 0ce35188..cc8db796 100644 --- a/docker/prod/api/docker-compose-prod.yml +++ b/docker/prod/api/docker-compose-prod.yml @@ -24,7 +24,7 @@ services: SUPERTONE_API_TOKEN: ${SUPERTONE_API_TOKEN} GOOGLE_CLIENT_ID_PROD: ${GOOGLE_CLIENT_ID_PROD} GOOGLE_CLIENT_SECRET_PROD: ${GOOGLE_CLIENT_SECRET_PROD} - PAYMENT_BASE_URL_PROD: ${PAYMENT_BASE_URL_PROD} + WIDGET_SECRET_KEY_PROD: ${WIDGET_SECRET_KEY_PROD} nginx: image: nginx:1.28.0 diff --git a/src/main/java/com/samhap/kokomen/answer/service/AnswerService.java b/src/main/java/com/samhap/kokomen/answer/service/AnswerService.java index 2c21d01a..cf2026f5 100644 --- a/src/main/java/com/samhap/kokomen/answer/service/AnswerService.java +++ b/src/main/java/com/samhap/kokomen/answer/service/AnswerService.java @@ -3,6 +3,7 @@ import com.samhap.kokomen.answer.domain.Answer; import com.samhap.kokomen.answer.repository.AnswerRepository; import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.exception.NotFoundException; import com.samhap.kokomen.interview.entity.Question; import java.util.List; import java.util.Optional; @@ -23,12 +24,12 @@ public Answer saveAnswer(Answer answer) { public Answer readById(Long answerId) { return answerRepository.findById(answerId) - .orElseThrow(() -> new BadRequestException("존재하지 않는 답변입니다.")); + .orElseThrow(() -> new NotFoundException("존재하지 않는 답변입니다.")); } public Answer readByQuestionId(Long questionId) { return answerRepository.findByQuestionId(questionId) - .orElseThrow(() -> new IllegalStateException("다음 질문에 대한 답변이 존재하지 않습니다. question_id = " + questionId)); + .orElseThrow(() -> new NotFoundException("다음 질문에 대한 답변이 존재하지 않습니다. question_id = " + questionId)); } public Optional findByQuestionId(Long questionId) { diff --git a/src/main/java/com/samhap/kokomen/auth/infrastructure/SessionInvalidator.java b/src/main/java/com/samhap/kokomen/auth/infrastructure/SessionInvalidator.java index d8efebf5..d9e240ca 100644 --- a/src/main/java/com/samhap/kokomen/auth/infrastructure/SessionInvalidator.java +++ b/src/main/java/com/samhap/kokomen/auth/infrastructure/SessionInvalidator.java @@ -1,5 +1,6 @@ package com.samhap.kokomen.auth.infrastructure; +import com.samhap.kokomen.global.exception.BadRequestException; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -15,7 +16,7 @@ public static void logout(HttpServletRequest request, HttpServletResponse respon Cookie jSessionIdCookie = Arrays.stream(request.getCookies()) .filter(cookie -> "JSESSIONID".equals(cookie.getName())) .findFirst() - .orElseThrow(() -> new IllegalStateException("JSESSIONID 쿠키가 요청에 존재하지 않습니다.")); + .orElseThrow(() -> new BadRequestException("JSESSIONID 쿠키가 요청에 존재하지 않습니다.")); jSessionIdCookie.setValue(""); jSessionIdCookie.setMaxAge(0); response.addCookie(jSessionIdCookie); diff --git a/src/main/java/com/samhap/kokomen/global/aop/PaymentLoggingAspect.java b/src/main/java/com/samhap/kokomen/global/aop/PaymentLoggingAspect.java deleted file mode 100644 index 44c4fe1a..00000000 --- a/src/main/java/com/samhap/kokomen/global/aop/PaymentLoggingAspect.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.samhap.kokomen.global.aop; - -import lombok.extern.slf4j.Slf4j; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Component; - -@Slf4j -@Order(1) -@Aspect -@Component -public class PaymentLoggingAspect { - - @Around("execution(* com.samhap.kokomen.token.external.PaymentClient.*(..))") - public Object logPaymentApiCall(ProceedingJoinPoint joinPoint) throws Throwable { - String methodName = joinPoint.getSignature().getName(); - Object[] args = joinPoint.getArgs(); - - log.info("[Payment API 요청] {} - args: {}", methodName, args); - - Object result = joinPoint.proceed(); - log.info("[Payment API 응답] {} - response: {}", methodName, result); - return result; - } -} diff --git a/src/main/java/com/samhap/kokomen/global/aop/TosspaymentsLoggingAspect.java b/src/main/java/com/samhap/kokomen/global/aop/TosspaymentsLoggingAspect.java new file mode 100644 index 00000000..4fa6e453 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/aop/TosspaymentsLoggingAspect.java @@ -0,0 +1,39 @@ +package com.samhap.kokomen.global.aop; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.util.StopWatch; + +@Slf4j +@Order(1) +@Aspect +@Component +public class TosspaymentsLoggingAspect { + + @Around("execution(* com.samhap.kokomen.payment.external.TosspaymentsClient.*(..))") + public Object logTosspaymentsApiCall(ProceedingJoinPoint joinPoint) throws Throwable { + String methodName = joinPoint.getSignature().getName(); + Object[] args = joinPoint.getArgs(); + + log.info("[토스페이먼츠 API 요청] {} - args: {}", methodName, args); + + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + try { + Object result = joinPoint.proceed(); + stopWatch.stop(); + log.info("[토스페이먼츠 API 응답] {} - {}ms - response: {}", + methodName, stopWatch.getTotalTimeMillis(), result); + return result; + } catch (Throwable e) { + stopWatch.stop(); + log.error("[토스페이먼츠 API 에러] {} - {}ms - error: {}", + methodName, stopWatch.getTotalTimeMillis(), e.getMessage()); + throw e; + } + } +} diff --git a/src/main/java/com/samhap/kokomen/global/config/RetryConfig.java b/src/main/java/com/samhap/kokomen/global/config/RetryConfig.java new file mode 100644 index 00000000..ecf52757 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/config/RetryConfig.java @@ -0,0 +1,39 @@ +package com.samhap.kokomen.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.backoff.ExponentialBackOffPolicy; +import org.springframework.retry.support.RetryTemplate; + +@Configuration +public class RetryConfig { + + @Value("${retry.tosspayments.max-attempts}") + private int maxAttempts; + + @Value("${retry.tosspayments.initial-interval}") + private long initialInterval; + + @Value("${retry.tosspayments.multiplier}") + private double multiplier; + + @Value("${retry.tosspayments.max-interval}") + private long maxInterval; + + @Bean + public RetryTemplate tosspaymentsConfirmRetryTemplate() { + RetryTemplate retryTemplate = new RetryTemplate(); + + TosspaymentsConfirmRetryPolicy retryPolicy = new TosspaymentsConfirmRetryPolicy(maxAttempts); + retryTemplate.setRetryPolicy(retryPolicy); + + ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy(); + backOffPolicy.setInitialInterval(initialInterval); + backOffPolicy.setMultiplier(multiplier); + backOffPolicy.setMaxInterval(maxInterval); + retryTemplate.setBackOffPolicy(backOffPolicy); + + return retryTemplate; + } +} diff --git a/src/main/java/com/samhap/kokomen/global/config/TosspaymentsConfirmRetryPolicy.java b/src/main/java/com/samhap/kokomen/global/config/TosspaymentsConfirmRetryPolicy.java new file mode 100644 index 00000000..9cd50833 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/config/TosspaymentsConfirmRetryPolicy.java @@ -0,0 +1,36 @@ +package com.samhap.kokomen.global.config; + +import org.springframework.retry.RetryContext; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; + +public class TosspaymentsConfirmRetryPolicy extends SimpleRetryPolicy { + + public TosspaymentsConfirmRetryPolicy(int maxAttempts) { + super(maxAttempts); + } + + @Override + public boolean canRetry(RetryContext context) { + Throwable lastException = context.getLastThrowable(); + if (lastException != null && !isRetryableException(lastException)) { + return false; + } + return super.canRetry(context); + } + + private boolean isRetryableException(Throwable throwable) { + if (throwable instanceof HttpServerErrorException) { + return true; + } + if (throwable instanceof ResourceAccessException) { + return true; + } + if (throwable instanceof HttpClientErrorException e) { + return e.getStatusCode().value() == 409; + } + return false; + } +} diff --git a/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java b/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java index 40beba6c..f81dba68 100644 --- a/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java +++ b/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java @@ -7,4 +7,8 @@ public class BadRequestException extends KokomenException { public BadRequestException(String message) { super(message, HttpStatus.BAD_REQUEST); } + + public BadRequestException(String message, Throwable cause) { + super(message, cause, HttpStatus.BAD_REQUEST); + } } diff --git a/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java index bdb378bc..cc9057ab 100644 --- a/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -6,13 +6,13 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.servlet.resource.NoResourceFoundException; -// TODO: HttpMessageNotReadableException 예외 처리 추가 @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @@ -93,6 +93,14 @@ public ResponseEntity handleNoResourceFound(NoResourceFoundException e) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity handleHttpMediaTypeNotSupportedException( + HttpMediaTypeNotSupportedException e) { + log.warn("HttpMediaTypeNotSupportedException :: message: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + .body(new ErrorResponse("지원하지 않는 Content-Type입니다.")); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, diff --git a/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java b/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java new file mode 100644 index 00000000..08a2af85 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java @@ -0,0 +1,14 @@ +package com.samhap.kokomen.global.exception; + +import org.springframework.http.HttpStatus; + +public class InternalServerErrorException extends KokomenException { + + public InternalServerErrorException(String message) { + super(message, HttpStatus.INTERNAL_SERVER_ERROR); + } + + public InternalServerErrorException(String message, Throwable cause) { + super(message, cause, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java b/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java new file mode 100644 index 00000000..fc7bdc1b --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java @@ -0,0 +1,14 @@ +package com.samhap.kokomen.global.exception; + +import org.springframework.http.HttpStatus; + +public class NotFoundException extends KokomenException { + + public NotFoundException(String message) { + super(message, HttpStatus.NOT_FOUND); + } + + public NotFoundException(String message, Throwable cause) { + super(message, cause, HttpStatus.NOT_FOUND); + } +} diff --git a/src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java b/src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java new file mode 100644 index 00000000..9f66fe08 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java @@ -0,0 +1,25 @@ +package com.samhap.kokomen.global.exception; + +import lombok.Getter; + +@Getter +public enum PaymentServiceErrorMessage { + + MISSING_REQUEST_PARAMETER("필수 요청 파라미터가 누락되었습니다."), + JSON_PARSE_ERROR("JSON 파싱 오류: 유효하지 않은 값이 전달되었습니다."), + PAYMENT_NOT_FOUND_BY_ID("해당 id의 결제 정보가 존재하지 않습니다."), + PAYMENT_NOT_FOUND_BY_PAYMENT_KEY("해당 paymentKey의 결제 정보가 존재하지 않습니다."), + PAYMENT_RESULT_NOT_FOUND("해당 결제의 결과 정보가 존재하지 않습니다."), + CONFIRM_SERVER_ERROR("결제 처리 중 서버 오류가 발생했습니다."), + CANCEL_SERVER_ERROR("결제 취소 처리 중 서버 오류가 발생했습니다."), + CANCEL_NETWORK_ERROR("결제 취소 처리 중 네트워크 오류가 발생했습니다."), + INVALID_REQUEST("잘못된 요청입니다."), + INVALID_REQUEST_FORMAT("잘못된 요청 형식입니다."), + INTERNAL_SERVER_ERROR("서버에 문제가 발생하였습니다."); + + private final String message; + + PaymentServiceErrorMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java b/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java index 10825ed4..dfda5781 100644 --- a/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java +++ b/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java @@ -2,6 +2,7 @@ import com.samhap.kokomen.global.annotation.Authentication; import com.samhap.kokomen.global.dto.MemberAuth; +import com.samhap.kokomen.global.exception.InternalServerErrorException; import com.samhap.kokomen.global.exception.UnauthorizedException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; @@ -29,7 +30,7 @@ public Object resolveArgument(MethodParameter parameter, WebDataBinderFactory binderFactory) throws Exception { Authentication authentication = parameter.getParameterAnnotation(Authentication.class); if (authentication == null) { - throw new IllegalStateException("MemberAuth 파라미터는 @Authentication 어노테이션이 있어야 합니다."); + throw new InternalServerErrorException("MemberAuth 파라미터는 @Authentication 어노테이션이 있어야 합니다."); } boolean authenticationRequired = authentication.required(); HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); @@ -56,7 +57,7 @@ private void validateAuthentication(Long memberId, boolean authenticationRequire log.error("세션에 MEMBER_ID가 없습니다."); } if (memberId == null && authenticationRequired) { - throw new IllegalStateException("세션에 MEMBER_ID가 없습니다."); + throw new UnauthorizedException("세션에 MEMBER_ID가 없습니다."); } } } diff --git a/src/main/java/com/samhap/kokomen/global/infrastructure/ObjectToStringDeserializer.java b/src/main/java/com/samhap/kokomen/global/infrastructure/ObjectToStringDeserializer.java new file mode 100644 index 00000000..38fa8786 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/global/infrastructure/ObjectToStringDeserializer.java @@ -0,0 +1,25 @@ +package com.samhap.kokomen.global.infrastructure; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; + +public class ObjectToStringDeserializer extends JsonDeserializer { + + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.getCodec().readTree(p); + + // 이미 문자열인 경우 그대로 반환 + if (node.isTextual()) { + return node.asText(); + } + + // 객체나 배열인 경우 JSON 문자열로 변환 + ObjectMapper mapper = new ObjectMapper(); + return mapper.writeValueAsString(node); + } +} diff --git a/src/main/java/com/samhap/kokomen/global/service/S3Service.java b/src/main/java/com/samhap/kokomen/global/service/S3Service.java index bb830cdc..01135dce 100644 --- a/src/main/java/com/samhap/kokomen/global/service/S3Service.java +++ b/src/main/java/com/samhap/kokomen/global/service/S3Service.java @@ -2,6 +2,7 @@ import com.samhap.kokomen.global.annotation.ExecutionTimer; import com.samhap.kokomen.global.constant.AwsConstant; +import com.samhap.kokomen.global.exception.BadRequestException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import software.amazon.awssdk.core.ResponseBytes; @@ -69,6 +70,6 @@ private String extractKeyFromCdnUrl(String cdnUrl) { if (cdnUrl.startsWith(AwsConstant.CLOUD_FRONT_DOMAIN_URL)) { return cdnUrl.substring(AwsConstant.CLOUD_FRONT_DOMAIN_URL.length()); } - throw new IllegalArgumentException("Invalid CDN URL: " + cdnUrl); + throw new BadRequestException("Invalid CDN URL: " + cdnUrl); } } diff --git a/src/main/java/com/samhap/kokomen/interview/domain/QuestionAndAnswers.java b/src/main/java/com/samhap/kokomen/interview/domain/QuestionAndAnswers.java index 3eafbc6c..e7f26809 100644 --- a/src/main/java/com/samhap/kokomen/interview/domain/QuestionAndAnswers.java +++ b/src/main/java/com/samhap/kokomen/interview/domain/QuestionAndAnswers.java @@ -20,7 +20,8 @@ public class QuestionAndAnswers { private final Interview interview; // TODO: 생성자에서 예외가 발생하더라도 객체는 힙에 남아있는지 실험해보기 - public QuestionAndAnswers(List questions, List prevAnswers, String curAnswerContent, Long curQuestionId, Interview interview) { + public QuestionAndAnswers(List questions, List prevAnswers, String curAnswerContent, + Long curQuestionId, Interview interview) { this.questions = questions.stream() .sorted(Comparator.comparing(Question::getId)) .toList(); @@ -43,7 +44,7 @@ private void validateInterviewProceed() { private void validateQuestionsAndAnswersSize() { if (questions.size() != prevAnswers.size() + 1) { - throw new IllegalArgumentException("질문과 답변의 개수가 일치하지 않습니다."); + throw new BadRequestException("질문과 답변의 개수가 일치하지 않습니다."); } } diff --git a/src/main/java/com/samhap/kokomen/interview/service/RootQuestionService.java b/src/main/java/com/samhap/kokomen/interview/service/RootQuestionService.java index 68503444..24378388 100644 --- a/src/main/java/com/samhap/kokomen/interview/service/RootQuestionService.java +++ b/src/main/java/com/samhap/kokomen/interview/service/RootQuestionService.java @@ -1,6 +1,7 @@ package com.samhap.kokomen.interview.service; import com.samhap.kokomen.category.domain.Category; +import com.samhap.kokomen.global.exception.NotFoundException; import com.samhap.kokomen.global.service.S3Service; import com.samhap.kokomen.interview.domain.QuestionVoicePathResolver; import com.samhap.kokomen.interview.entity.RootQuestion; @@ -37,7 +38,7 @@ public RootQuestion findNextRootQuestionForMember(Member member, InterviewReques RootQuestion lastRootQuestionReceived = rootQuestionRepository.findLastRootQuestionMemberReceivedByCategory(category, member.getId(), RootQuestionState.ACTIVE) - .orElseThrow(() -> new IllegalStateException("해당 카테고리의 질문을 찾을 수 없습니다.")); + .orElseThrow(() -> new NotFoundException("해당 카테고리의 질문을 찾을 수 없습니다.")); int nextOrder = lastRootQuestionReceived.getQuestionOrder() + 1; return rootQuestionRepository.findRootQuestionByCategoryAndStateAndQuestionOrder(category, @@ -48,7 +49,7 @@ public RootQuestion findNextRootQuestionForMember(Member member, InterviewReques private RootQuestion findFirstRootQuestion(Category category) { return rootQuestionRepository.findRootQuestionByCategoryAndStateAndQuestionOrder(category, RootQuestionState.ACTIVE, 1) - .orElseThrow(() -> new IllegalStateException("해당 카테고리의 질문을 찾을 수 없습니다.")); + .orElseThrow(() -> new NotFoundException("해당 카테고리의 질문을 찾을 수 없습니다.")); } public boolean isRootQuestionVoiceExists(Long rootQuestionId) { @@ -78,7 +79,7 @@ public String createAndUploadRootQuestionVoiceWithApiKey(Long rootQuestionId, St public RootQuestion readRootQuestion(Long rootQuestionId) { return rootQuestionRepository.findById(rootQuestionId) .orElseThrow( - () -> new IllegalArgumentException("루트 질문이 존재하지 않습니다. rootQuestionId = " + rootQuestionId)); + () -> new NotFoundException("루트 질문이 존재하지 않습니다. rootQuestionId = " + rootQuestionId)); } public List findAllRootQuestionByCategory(Category category) { diff --git a/src/main/java/com/samhap/kokomen/payment/domain/PaymentErrorMessage.java b/src/main/java/com/samhap/kokomen/payment/domain/PaymentErrorMessage.java new file mode 100644 index 00000000..acaf60a1 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/PaymentErrorMessage.java @@ -0,0 +1,17 @@ +package com.samhap.kokomen.payment.domain; + +import lombok.Getter; + +@Getter +public enum PaymentErrorMessage { + + PAYMENT_KEY_MISMATCH("토스 페이먼츠 응답의 paymentKey가 DB에 저장된 값과 다릅니다."), + ORDER_ID_MISMATCH("토스 페이먼츠 응답의 orderId가 DB에 저장된 값과 다릅니다."), + TOTAL_AMOUNT_MISMATCH("토스 페이먼츠 응답의 totalAmount가 DB에 저장된 값과 다릅니다."); + + private final String message; + + PaymentErrorMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/domain/PaymentState.java b/src/main/java/com/samhap/kokomen/payment/domain/PaymentState.java new file mode 100644 index 00000000..65662fd7 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/PaymentState.java @@ -0,0 +1,14 @@ +package com.samhap.kokomen.payment.domain; + +public enum PaymentState { + NEED_APPROVE, // 결제 승인 대기 상태가 오래 지속되는 경우 결제 취소 필요 + APPROVED, // 결제 완료 후 비즈니스 반영이 안된 상태 + NOT_NEED_CANCEL, // NEED_CANCEL 상태에서 환불처리 시도했으나 애초에 결제가 안 된 것으로 확인된 경우 + NEED_CANCEL, // 리드 타임 아웃 or 토스페이먼츠 5xx 응답인 경우 결제 취소 필요 + CONNECTION_TIMEOUT, // 연결 타임 아웃인 경우에는 환불 처리 불필요 + CANCELED, // 환불 처리 완료 + CLIENT_BAD_REQUEST, // 클라이언트 문제로 토스페이먼츠로부터 400을 받은 경우 사용자에게 메시지 노출 필요 + SERVER_BAD_REQUEST, // 서버 문제로 토스페이먼츠로부터 400을 받은 경우 사용자에게 메시지 노출 불필요 + COMPLETED, // 결제 완료 후 비즈니스 반영도 완료된 상태 + ; +} diff --git a/src/main/java/com/samhap/kokomen/payment/domain/PaymentType.java b/src/main/java/com/samhap/kokomen/payment/domain/PaymentType.java new file mode 100644 index 00000000..56fb5e00 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/PaymentType.java @@ -0,0 +1,7 @@ +package com.samhap.kokomen.payment.domain; + +public enum PaymentType { + NORMAL, + BILLING, + BRANDPAY +} diff --git a/src/main/java/com/samhap/kokomen/payment/domain/ServiceType.java b/src/main/java/com/samhap/kokomen/payment/domain/ServiceType.java new file mode 100644 index 00000000..94030eb4 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/ServiceType.java @@ -0,0 +1,6 @@ +package com.samhap.kokomen.payment.domain; + +public enum ServiceType { + INTERVIEW, + ; +} diff --git a/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java new file mode 100644 index 00000000..3eddbe27 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java @@ -0,0 +1,118 @@ +package com.samhap.kokomen.payment.domain; + +import com.samhap.kokomen.global.domain.BaseEntity; +import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.exception.InternalServerErrorException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.annotation.LastModifiedDate; + +@Slf4j +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "tosspayments_payment", indexes = { + @Index(name = "idx_payment_member_id", columnList = "member_id") +}) +public class TosspaymentsPayment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "payment_key", nullable = false, unique = true) + private String paymentKey; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "order_id", nullable = false, unique = true) + private String orderId; + + @Column(name = "order_name", nullable = false) + private String orderName; + + @Column(name = "total_amount", nullable = false) + private Long totalAmount; + + @Column(name = "metadata", columnDefinition = "json", nullable = false) + private String metadata; + + @Column(name = "state", nullable = false) + @Enumerated(EnumType.STRING) + private PaymentState state; + + @Column(name = "service_type", nullable = false) + @Enumerated(EnumType.STRING) + private ServiceType serviceType; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + public TosspaymentsPayment(String paymentKey, Long memberId, String orderId, String orderName, Long totalAmount, + String metadata, ServiceType serviceType) { + validateConstructorParams(paymentKey, memberId, orderId, totalAmount); + this.paymentKey = paymentKey; + this.memberId = memberId; + this.orderId = orderId; + this.orderName = orderName; + this.totalAmount = totalAmount; + this.metadata = metadata; + this.serviceType = serviceType; + this.state = PaymentState.NEED_APPROVE; + } + + private void validateConstructorParams(String paymentKey, Long memberId, String orderId, Long totalAmount) { + if (paymentKey == null || paymentKey.isBlank()) { + throw new BadRequestException("paymentKey는 필수입니다."); + } + if (memberId == null) { + throw new BadRequestException("memberId는 필수입니다."); + } + if (orderId == null || orderId.isBlank()) { + throw new BadRequestException("orderId는 필수입니다."); + } + if (totalAmount == null || totalAmount < 0) { + throw new BadRequestException("totalAmount는 0 이상이어야 합니다."); + } + } + + public void updateState(PaymentState state) { + this.state = state; + } + + public void validateTosspaymentsResult(String paymentKey, String orderId, Long totalAmount) { + if (!this.paymentKey.equals(paymentKey)) { + log.error("paymentKey 불일치 - 응답: {}, DB: {}", maskPaymentKey(paymentKey), maskPaymentKey(this.paymentKey)); + throw new InternalServerErrorException(PaymentErrorMessage.PAYMENT_KEY_MISMATCH.getMessage()); + } + if (!this.orderId.equals(orderId)) { + log.error("orderId 불일치 - 응답: {}, DB: {}", orderId, this.orderId); + throw new InternalServerErrorException(PaymentErrorMessage.ORDER_ID_MISMATCH.getMessage()); + } + if (!this.totalAmount.equals(totalAmount)) { + log.error("totalAmount 불일치 - 응답: {}, DB: {}", totalAmount, this.totalAmount); + throw new InternalServerErrorException(PaymentErrorMessage.TOTAL_AMOUNT_MISMATCH.getMessage()); + } + } + + private String maskPaymentKey(String key) { + if (key == null || key.length() <= 8) { + return "***"; + } + return key.substring(0, 8) + "***"; + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResult.java b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResult.java new file mode 100644 index 00000000..94323406 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResult.java @@ -0,0 +1,155 @@ +package com.samhap.kokomen.payment.domain; + +import com.samhap.kokomen.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.LastModifiedDate; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class TosspaymentsPaymentResult extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tosspayments_payment_id", nullable = false) + private TosspaymentsPayment tosspaymentsPayment; + + @Column(name = "type", nullable = false) + @Enumerated(EnumType.STRING) + private PaymentType type; + + @Column(name = "m_id", nullable = false) + private String mId; + + @Column(name = "currency", nullable = false) + private String currency; + + @Column(name = "method") + private String method; + + @Column(name = "total_amount", nullable = false) + private Long totalAmount; + + @Column(name = "balance_amount", nullable = false) + private Long balanceAmount; + + @Column(name = "tosspayments_status", nullable = false) + @Enumerated(EnumType.STRING) + private TosspaymentsStatus tosspaymentsStatus; + + @Column(name = "requested_at", nullable = false) + private LocalDateTime requestedAt; + + @Column(name = "approved_at") + private LocalDateTime approvedAt; + + @Column(name = "last_transaction_key") + private String lastTransactionKey; + + @Column(name = "supplied_amount") + private Long suppliedAmount; + + @Column(name = "vat") + private Long vat; + + @Column(name = "tax_free_amount") + private Long taxFreeAmount; + + @Column(name = "tax_exemption_amount") + private Long taxExemptionAmount; + + @Column(name = "is_partial_cancelable", nullable = false) + private boolean isPartialCancelable; + + @Column(name = "receipt_url") + private String receiptUrl; + + @Column(name = "easy_pay_provider") + private String easyPayProvider; + + @Column(name = "easy_pay_amount") + private Long easyPayAmount; + + @Column(name = "easy_pay_discount_amount") + private Long easyPayDiscountAmount; + + @Column(name = "country") + private String country; + + @Column(name = "failure_code") + private String failureCode; + + @Column(name = "failure_message") + private String failureMessage; + + @Column(name = "cancel_reason") + private String cancelReason; + + @Column(name = "canceled_at") + private LocalDateTime canceledAt; + + @Column(name = "cancel_status") + private String cancelStatus; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + @Builder + public TosspaymentsPaymentResult(TosspaymentsPayment tosspaymentsPayment, PaymentType type, String mId, String currency, Long totalAmount, String method, + Long balanceAmount, TosspaymentsStatus tosspaymentsStatus, LocalDateTime requestedAt, LocalDateTime approvedAt, + String lastTransactionKey, Long suppliedAmount, Long vat, Long taxFreeAmount, Long taxExemptionAmount, + boolean isPartialCancelable, String receiptUrl, String easyPayProvider, Long easyPayAmount, + Long easyPayDiscountAmount, String country, String failureCode, String failureMessage) { + this.tosspaymentsPayment = tosspaymentsPayment; + this.type = type; + this.mId = mId; + this.currency = currency; + this.totalAmount = totalAmount; + this.method = method; + this.balanceAmount = balanceAmount; + this.tosspaymentsStatus = tosspaymentsStatus; + this.requestedAt = requestedAt; + this.approvedAt = approvedAt; + this.lastTransactionKey = lastTransactionKey; + this.suppliedAmount = suppliedAmount; + this.vat = vat; + this.taxFreeAmount = taxFreeAmount; + this.taxExemptionAmount = taxExemptionAmount; + this.isPartialCancelable = isPartialCancelable; + this.receiptUrl = receiptUrl; + this.easyPayProvider = easyPayProvider; + this.easyPayAmount = easyPayAmount; + this.easyPayDiscountAmount = easyPayDiscountAmount; + this.country = country; + this.failureCode = failureCode; + this.failureMessage = failureMessage; + } + + public void updateCancelInfo(String cancelReason, LocalDateTime canceledAt, Long easyPayDiscountAmount, + String lastTransactionKey, String cancelStatus, TosspaymentsStatus tosspaymentsStatus) { + this.cancelReason = cancelReason; + this.canceledAt = canceledAt; + this.easyPayDiscountAmount = easyPayDiscountAmount; + this.lastTransactionKey = lastTransactionKey; + this.cancelStatus = cancelStatus; + this.tosspaymentsStatus = tosspaymentsStatus; + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java new file mode 100644 index 00000000..b5982142 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java @@ -0,0 +1,12 @@ +package com.samhap.kokomen.payment.domain; + +public enum TosspaymentsStatus { + READY, + IN_PROGRESS, + WAITING_FOR_DEPOSIT, + DONE, + CANCELED, + PARTIAL_CANCELED, + ABORTED, + EXPIRED +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java b/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java new file mode 100644 index 00000000..f2084de4 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java @@ -0,0 +1,53 @@ +package com.samhap.kokomen.payment.external; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Getter +@Component +public class TossPaymentsClientBuilder { + + private static final String TOSSPAYMENTS_API_URL = "https://api.tosspayments.com"; + + private final RestClient.Builder tossPaymentsClientBuilder; + + public TossPaymentsClientBuilder( + RestClient.Builder builder, + @Value("${tosspayments.widget-secret-key}") String tossPaymentsWidgetSecretKey) { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(3000); + requestFactory.setReadTimeout(15000); + + String encodedSecretKey = Base64.getEncoder().encodeToString((tossPaymentsWidgetSecretKey + ":").getBytes(StandardCharsets.UTF_8)); + + this.tossPaymentsClientBuilder = builder + .requestFactory(requestFactory) + .baseUrl(TOSSPAYMENTS_API_URL) + .defaultHeader("Authorization", "Basic " + encodedSecretKey) + .defaultHeader("Content-Type", "application/json") + .messageConverters(converters -> { + converters.removeIf(MappingJackson2HttpMessageConverter.class::isInstance); + converters.add(new MappingJackson2HttpMessageConverter(createObjectMapper())); + }); + } + + private ObjectMapper createObjectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE); + return objectMapper; + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java b/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java new file mode 100644 index 00000000..6640d329 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java @@ -0,0 +1,37 @@ +package com.samhap.kokomen.payment.external; + +import com.samhap.kokomen.payment.external.dto.TosspaymentsConfirmRequest; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentCancelRequest; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +public class TosspaymentsClient { + + private final RestClient restClient; + + public TosspaymentsClient(TossPaymentsClientBuilder tossPaymentsClientBuilder) { + this.restClient = tossPaymentsClientBuilder.getTossPaymentsClientBuilder().build(); + } + + public TosspaymentsPaymentResponse confirmPayment(TosspaymentsConfirmRequest request, String idempotencyKey) { + return restClient.post() + .uri("/v1/payments/confirm") + .header("Idempotency-Key", idempotencyKey) + .body(request) + .retrieve() + .body(TosspaymentsPaymentResponse.class); + } + + public TosspaymentsPaymentResponse cancelPayment( + String paymentKey, + TosspaymentsPaymentCancelRequest request + ) { + return restClient.post() + .uri("/v1/payments/{paymentKey}/cancel", paymentKey) + .body(request) + .retrieve() + .body(TosspaymentsPaymentResponse.class); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsInternalServerErrorCode.java b/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsInternalServerErrorCode.java new file mode 100644 index 00000000..01c39dca --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/TosspaymentsInternalServerErrorCode.java @@ -0,0 +1,22 @@ +package com.samhap.kokomen.payment.external; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +public enum TosspaymentsInternalServerErrorCode { + + INVALID_API_KEY, + INVALID_AUTHORIZE_AUTH, + UNAUTHORIZED_KEY, + INCORRECT_BASIC_AUTH_FORMAT, + ; + + private static final Set CODES = Arrays.stream(values()) + .map(TosspaymentsInternalServerErrorCode::name) + .collect(Collectors.toSet()); + + public static boolean contains(String code) { + return CODES.contains(code); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/Checkout.java b/src/main/java/com/samhap/kokomen/payment/external/dto/Checkout.java new file mode 100644 index 00000000..e4374f2e --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/Checkout.java @@ -0,0 +1,6 @@ +package com.samhap.kokomen.payment.external.dto; + +public record Checkout( + String url +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/EasyPay.java b/src/main/java/com/samhap/kokomen/payment/external/dto/EasyPay.java new file mode 100644 index 00000000..e441b2df --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/EasyPay.java @@ -0,0 +1,8 @@ +package com.samhap.kokomen.payment.external.dto; + +public record EasyPay( + String provider, + Long amount, + Long discountAmount +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/Failure.java b/src/main/java/com/samhap/kokomen/payment/external/dto/Failure.java new file mode 100644 index 00000000..d20fc8ae --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/Failure.java @@ -0,0 +1,7 @@ +package com.samhap.kokomen.payment.external.dto; + +public record Failure( + String code, + String message +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/Receipt.java b/src/main/java/com/samhap/kokomen/payment/external/dto/Receipt.java new file mode 100644 index 00000000..9637829f --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/Receipt.java @@ -0,0 +1,6 @@ +package com.samhap.kokomen.payment.external.dto; + +public record Receipt( + String url +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TossDateTimeDeserializer.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TossDateTimeDeserializer.java new file mode 100644 index 00000000..a002dc8b --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TossDateTimeDeserializer.java @@ -0,0 +1,20 @@ +package com.samhap.kokomen.payment.external.dto; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +public class TossDateTimeDeserializer extends JsonDeserializer { + + @Override + public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String dateTimeStr = p.getText(); + OffsetDateTime offsetDateTime = OffsetDateTime.parse(dateTimeStr, DateTimeFormatter.ISO_OFFSET_DATE_TIME); + return offsetDateTime.atZoneSameInstant(ZoneId.of("Asia/Seoul")).toLocalDateTime(); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsCancel.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsCancel.java new file mode 100644 index 00000000..9ae115f7 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsCancel.java @@ -0,0 +1,20 @@ +package com.samhap.kokomen.payment.external.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.time.LocalDateTime; + +public record TosspaymentsCancel( + String transactionKey, + String cancelReason, + Long taxExemptionAmount, + @JsonDeserialize(using = TossDateTimeDeserializer.class) + LocalDateTime canceledAt, + Long easyPayDiscountAmount, + String receiptKey, + Long cancelAmount, + Long taxFreeAmount, + Long refundableAmount, + String cancelStatus, + String cancelRequestId +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsConfirmRequest.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsConfirmRequest.java new file mode 100644 index 00000000..db38d76c --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsConfirmRequest.java @@ -0,0 +1,16 @@ +package com.samhap.kokomen.payment.external.dto; + +import java.util.Objects; + +public record TosspaymentsConfirmRequest( + String paymentKey, + String orderId, + Long amount +) { + + public TosspaymentsConfirmRequest { + Objects.requireNonNull(paymentKey, "paymentKey는 필수입니다."); + Objects.requireNonNull(orderId, "orderId는 필수입니다."); + Objects.requireNonNull(amount, "amount는 필수입니다."); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java new file mode 100644 index 00000000..9812da9a --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentCancelRequest.java @@ -0,0 +1,16 @@ +package com.samhap.kokomen.payment.external.dto; + +import com.samhap.kokomen.global.exception.BadRequestException; +import java.util.Objects; + +public record TosspaymentsPaymentCancelRequest( + String cancelReason +) { + + public TosspaymentsPaymentCancelRequest { + Objects.requireNonNull(cancelReason, "cancelReason은 필수입니다."); + if (cancelReason.isBlank()) { + throw new BadRequestException("cancelReason은 비어있을 수 없습니다."); + } + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentResponse.java b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentResponse.java new file mode 100644 index 00000000..47334fd5 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/external/dto/TosspaymentsPaymentResponse.java @@ -0,0 +1,69 @@ +package com.samhap.kokomen.payment.external.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.samhap.kokomen.global.infrastructure.ObjectToStringDeserializer; +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +import java.time.LocalDateTime; + +public record TosspaymentsPaymentResponse( + String paymentKey, + PaymentType type, + String orderId, + String orderName, + String mId, + String currency, + String method, + Long totalAmount, + Long balanceAmount, + TosspaymentsStatus status, + @JsonDeserialize(using = TossDateTimeDeserializer.class) + LocalDateTime requestedAt, + @JsonDeserialize(using = TossDateTimeDeserializer.class) + LocalDateTime approvedAt, + String lastTransactionKey, + Long suppliedAmount, + Long vat, + Long taxFreeAmount, + Long taxExemptionAmount, + boolean isPartialCancelable, + @JsonDeserialize(using = ObjectToStringDeserializer.class) + String metadata, + Receipt receipt, + Checkout checkout, + EasyPay easyPay, + String country, + Failure failure, + java.util.List cancels +) { + + public TosspaymentsPaymentResult toTosspaymentsPaymentResult(TosspaymentsPayment tosspaymentsPayment) { + return TosspaymentsPaymentResult.builder() + .tosspaymentsPayment(tosspaymentsPayment) + .type(this.type) + .mId(this.mId) + .currency(this.currency) + .totalAmount(this.totalAmount) + .method(this.method) + .balanceAmount(this.balanceAmount) + .tosspaymentsStatus(this.status) + .requestedAt(this.requestedAt) + .approvedAt(this.approvedAt) + .lastTransactionKey(this.lastTransactionKey) + .suppliedAmount(this.suppliedAmount) + .vat(this.vat) + .taxFreeAmount(this.taxFreeAmount) + .taxExemptionAmount(this.taxExemptionAmount) + .isPartialCancelable(this.isPartialCancelable) + .receiptUrl(this.receipt() != null ? this.receipt().url() : null) + .easyPayProvider(this.easyPay() != null ? this.easyPay().provider() : null) + .easyPayAmount(this.easyPay() != null ? this.easyPay().amount() : null) + .easyPayDiscountAmount(this.easyPay() != null ? this.easyPay().discountAmount() : null) + .country(this.country) + .failureCode(this.failure() != null ? this.failure().code() : null) + .failureMessage(this.failure() != null ? this.failure().message() : null) + .build(); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java b/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java new file mode 100644 index 00000000..89e8fc13 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java @@ -0,0 +1,10 @@ +package com.samhap.kokomen.payment.repository; + +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TosspaymentsPaymentRepository extends JpaRepository { + + Optional findByPaymentKey(String paymentKey); +} diff --git a/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentResultRepository.java b/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentResultRepository.java new file mode 100644 index 00000000..83110d43 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentResultRepository.java @@ -0,0 +1,10 @@ +package com.samhap.kokomen.payment.repository; + +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TosspaymentsPaymentResultRepository extends JpaRepository { + + Optional findByTosspaymentsPaymentId(Long tosspaymentsPaymentId); +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java b/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java new file mode 100644 index 00000000..b59a17e5 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java @@ -0,0 +1,167 @@ +package com.samhap.kokomen.payment.service; + +import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.exception.InternalServerErrorException; +import com.samhap.kokomen.global.exception.KokomenException; +import com.samhap.kokomen.global.exception.PaymentServiceErrorMessage; +import com.samhap.kokomen.payment.domain.PaymentState; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.external.TosspaymentsClient; +import com.samhap.kokomen.payment.external.TosspaymentsInternalServerErrorCode; +import com.samhap.kokomen.payment.external.dto.Failure; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentCancelRequest; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import com.samhap.kokomen.payment.service.dto.CancelRequest; +import com.samhap.kokomen.payment.service.dto.ConfirmRequest; +import com.samhap.kokomen.payment.service.dto.PaymentResponse; +import java.net.SocketTimeoutException; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; + +@Slf4j +@RequiredArgsConstructor +@Service +public class PaymentFacadeService { + + private final TosspaymentsTransactionService tosspaymentsTransactionService; + private final TosspaymentsPaymentService tosspaymentsPaymentService; + private final TosspaymentsClient tosspaymentsClient; + private final RetryTemplate tosspaymentsConfirmRetryTemplate; + + public PaymentResponse confirmPayment(ConfirmRequest request) { + TosspaymentsPayment tosspaymentsPayment = tosspaymentsPaymentService.saveTosspaymentsPayment(request); + try { + TosspaymentsPaymentResponse tosspaymentsPaymentResponse = confirmPayment(request, tosspaymentsPayment); + return PaymentResponse.from(tosspaymentsPaymentResponse); + } catch (KokomenException | HttpServerErrorException | ResourceAccessException e) { + // inner에서 상태 처리 완료 + throw e; + } catch (Exception e) { + // 예상치 못한 예외만 NEED_CANCEL 설정 + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + throw e; + } + } + + private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, + TosspaymentsPayment tosspaymentsPayment) { + String idempotencyKey = UUID.randomUUID().toString(); + try { + TosspaymentsPaymentResponse tosspaymentsConfirmResponse = tosspaymentsConfirmRetryTemplate.execute( + context -> { + if (context.getRetryCount() > 0) { + log.warn("토스페이먼츠 결제 승인 재시도 {}회차, paymentKey = {}", + context.getRetryCount(), request.paymentKey()); + } + return tosspaymentsClient.confirmPayment(request.toTosspaymentsConfirmRequest(), + idempotencyKey); + }); + tosspaymentsPayment.validateTosspaymentsResult(tosspaymentsConfirmResponse.paymentKey(), + tosspaymentsConfirmResponse.orderId(), + tosspaymentsConfirmResponse.totalAmount()); + TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult( + tosspaymentsPayment); + tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, + PaymentState.COMPLETED); + return tosspaymentsConfirmResponse; + } catch (HttpClientErrorException e) { + throw handleConfirmClientError(e, tosspaymentsPayment); + } catch (HttpServerErrorException e) { + handleConfirmServerError(e, tosspaymentsPayment); + throw e; + } catch (ResourceAccessException e) { + handleConfirmNetworkError(e, tosspaymentsPayment); + throw e; + } + } + + private RuntimeException handleConfirmClientError(HttpClientErrorException e, + TosspaymentsPayment tosspaymentsPayment) { + Failure failure = e.getResponseBodyAs(Failure.class); + if (failure == null) { + log.error("토스 결제 실패(400) - 응답 파싱 실패", e); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.SERVER_BAD_REQUEST); + return new InternalServerErrorException(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage(), e); + } + String code = failure.code(); + + if ("IDEMPOTENT_REQUEST_PROCESSING".equals(code)) { + log.error("토스 결제 처리 중 상태 지속 (409), paymentKey = {}", tosspaymentsPayment.getPaymentKey()); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + return new InternalServerErrorException(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage(), e); + } + + if (TosspaymentsInternalServerErrorCode.contains(code)) { + log.error("토스 결제 실패(서버 원인 400), code = {}, message = {}", code, failure.message()); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.SERVER_BAD_REQUEST); + return new InternalServerErrorException(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage(), e); + } + + log.info("토스 결제 실패(클라이언트 원인 400), code = {}, message = {}", code, failure.message()); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CLIENT_BAD_REQUEST); + return new BadRequestException(failure.message(), e); + } + + private void handleConfirmServerError(HttpServerErrorException e, TosspaymentsPayment tosspaymentsPayment) { + try { + TosspaymentsPaymentResponse tosspaymentsConfirmResponse = e.getResponseBodyAs( + TosspaymentsPaymentResponse.class); + TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult( + tosspaymentsPayment); + tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, + PaymentState.NEED_CANCEL); + } catch (Exception parseException) { + log.warn("토스 5xx 응답 파싱 실패, 상태만 업데이트합니다. paymentId = {}", tosspaymentsPayment.getId(), parseException); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + } + } + + private void handleConfirmNetworkError(ResourceAccessException e, TosspaymentsPayment tosspaymentsPayment) { + if (e.getRootCause() instanceof SocketTimeoutException socketTimeoutException) { + if (socketTimeoutException.getMessage().contains("Connect timed out")) { + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CONNECTION_TIMEOUT); + return; + } + if (socketTimeoutException.getMessage().contains("Read timed out")) { + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + return; + } + } + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + } + + public void cancelPayment(CancelRequest request) { + TosspaymentsPaymentCancelRequest tosspaymentsPaymentCancelRequest = new TosspaymentsPaymentCancelRequest( + request.cancelReason()); + try { + TosspaymentsPaymentResponse response = tosspaymentsClient.cancelPayment(request.paymentKey(), + tosspaymentsPaymentCancelRequest); + tosspaymentsTransactionService.applyCancelResult(response); + } catch (HttpClientErrorException e) { + Failure failure = e.getResponseBodyAs(Failure.class); + if (failure == null) { + log.error("결제 취소 실패(400) - 응답 파싱 실패, paymentKey: {}", request.paymentKey(), e); + throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage(), e); + } + log.error("결제 취소 실패(400) - paymentKey: {}, code: {}, message: {}", request.paymentKey(), failure.code(), + failure.message()); + throw new BadRequestException(failure.message(), e); + } catch (HttpServerErrorException e) { + log.error("결제 취소 실패(5xx) - paymentKey: {}, status: {}", request.paymentKey(), e.getStatusCode()); + throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage(), e); + } catch (ResourceAccessException e) { + log.error("결제 취소 네트워크 오류 - paymentKey: {}", request.paymentKey(), e); + throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_NETWORK_ERROR.getMessage(), e); + } catch (Exception e) { + log.error("결제 취소 중 예상치 못한 오류 - paymentKey: {}", request.paymentKey(), e); + throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage(), e); + } + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java new file mode 100644 index 00000000..d9ad13f3 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java @@ -0,0 +1,32 @@ +package com.samhap.kokomen.payment.service; + +import com.samhap.kokomen.global.exception.NotFoundException; +import com.samhap.kokomen.global.exception.PaymentServiceErrorMessage; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentResultRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class TosspaymentsPaymentResultService { + + private final TosspaymentsPaymentResultRepository tosspaymentsPaymentResultRepository; + + @Transactional + public TosspaymentsPaymentResult save(TosspaymentsPaymentResult tosspaymentsPaymentResult) { + return tosspaymentsPaymentResultRepository.save(tosspaymentsPaymentResult); + } + + @Transactional(readOnly = true) + public TosspaymentsPaymentResult readByTosspaymentsPaymentId(Long tosspaymentsPaymentId) { + return tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(tosspaymentsPaymentId) + .orElseThrow(() -> { + log.error("결제 결과 조회 실패 - tosspaymentsPaymentId: {}", tosspaymentsPaymentId); + return new NotFoundException(PaymentServiceErrorMessage.PAYMENT_RESULT_NOT_FOUND.getMessage()); + }); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java new file mode 100644 index 00000000..7304853c --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java @@ -0,0 +1,50 @@ +package com.samhap.kokomen.payment.service; + +import com.samhap.kokomen.global.exception.NotFoundException; +import com.samhap.kokomen.global.exception.PaymentServiceErrorMessage; +import com.samhap.kokomen.payment.domain.PaymentState; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentRepository; +import com.samhap.kokomen.payment.service.dto.ConfirmRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class TosspaymentsPaymentService { + + private final TosspaymentsPaymentRepository tosspaymentsPaymentRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public TosspaymentsPayment saveTosspaymentsPayment(ConfirmRequest request) { + return tosspaymentsPaymentRepository.save(request.toTosspaymentsPayment()); + } + + @Transactional(readOnly = true) + public TosspaymentsPayment readById(Long id) { + return tosspaymentsPaymentRepository.findById(id) + .orElseThrow(() -> { + log.error("결제 정보 조회 실패 - id: {}", id); + return new NotFoundException(PaymentServiceErrorMessage.PAYMENT_NOT_FOUND_BY_ID.getMessage()); + }); + } + + @Transactional(readOnly = true) + public TosspaymentsPayment readByPaymentKey(String paymentKey) { + return tosspaymentsPaymentRepository.findByPaymentKey(paymentKey) + .orElseThrow(() -> { + log.error("결제 정보 조회 실패 - paymentKey: {}", paymentKey); + return new NotFoundException(PaymentServiceErrorMessage.PAYMENT_NOT_FOUND_BY_PAYMENT_KEY.getMessage()); + }); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void updateState(Long tosspaymentsPaymentId, PaymentState state) { + TosspaymentsPayment tosspaymentsPayment = readById(tosspaymentsPaymentId); + tosspaymentsPayment.updateState(state); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java new file mode 100644 index 00000000..9a35bbcf --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionService.java @@ -0,0 +1,46 @@ +package com.samhap.kokomen.payment.service; + +import com.samhap.kokomen.payment.domain.PaymentState; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.external.dto.TosspaymentsCancel; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class TosspaymentsTransactionService { + + private final TosspaymentsPaymentService tosspaymentsPaymentService; + private final TosspaymentsPaymentResultService tosspaymentsPaymentResultService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public TosspaymentsPaymentResult applyTosspaymentsPaymentResult(TosspaymentsPaymentResult tosspaymentsPaymentResult, PaymentState paymentState) { + TosspaymentsPayment tosspaymentsPayment = tosspaymentsPaymentService.readById(tosspaymentsPaymentResult.getTosspaymentsPayment().getId()); + tosspaymentsPayment.updateState(paymentState); + return tosspaymentsPaymentResultService.save(tosspaymentsPaymentResult); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void applyCancelResult(TosspaymentsPaymentResponse response) { + TosspaymentsPayment payment = tosspaymentsPaymentService.readByPaymentKey(response.paymentKey()); + payment.updateState(PaymentState.CANCELED); + + TosspaymentsPaymentResult result = tosspaymentsPaymentResultService.readByTosspaymentsPaymentId(payment.getId()); + + if (response.cancels() != null && !response.cancels().isEmpty()) { + TosspaymentsCancel tosspaymentsCancel = response.cancels().get(response.cancels().size() - 1); + result.updateCancelInfo( + tosspaymentsCancel.cancelReason(), + tosspaymentsCancel.canceledAt(), + tosspaymentsCancel.easyPayDiscountAmount(), + response.lastTransactionKey(), + tosspaymentsCancel.cancelStatus(), + response.status() + ); + } + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/CancelRequest.java b/src/main/java/com/samhap/kokomen/payment/service/dto/CancelRequest.java new file mode 100644 index 00000000..25c9ef29 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/CancelRequest.java @@ -0,0 +1,15 @@ +package com.samhap.kokomen.payment.service.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CancelRequest( + @NotBlank(message = "paymentKey는 필수값입니다.") + @Size(max = 200, message = "paymentKey는 최대 200자입니다.") + String paymentKey, + + @NotBlank(message = "cancelReason은 필수값입니다.") + @Size(max = 200, message = "cancelReason은 최대 200자입니다.") + String cancelReason +) { +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/Checkout.java b/src/main/java/com/samhap/kokomen/payment/service/dto/Checkout.java new file mode 100644 index 00000000..efd45b2d --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/Checkout.java @@ -0,0 +1,10 @@ +package com.samhap.kokomen.payment.service.dto; + +public record Checkout( + String url +) { + + public static Checkout from(com.samhap.kokomen.payment.external.dto.Checkout checkout) { + return new Checkout(checkout.url()); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/ConfirmRequest.java b/src/main/java/com/samhap/kokomen/payment/service/dto/ConfirmRequest.java new file mode 100644 index 00000000..f0e5fe8b --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/ConfirmRequest.java @@ -0,0 +1,36 @@ +package com.samhap.kokomen.payment.service.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.samhap.kokomen.global.infrastructure.ObjectToStringDeserializer; +import com.samhap.kokomen.payment.domain.ServiceType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.external.dto.TosspaymentsConfirmRequest; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ConfirmRequest( + @NotBlank(message = "payment_key는 비어있거나 공백일 수 없습니다.") + String paymentKey, + @NotBlank(message = "order_id는 비어있거나 공백일 수 없습니다.") + String orderId, + @NotNull(message = "total_amount는 null일 수 없습니다.") + Long totalAmount, + @NotBlank(message = "order_name은 비어있거나 공백일 수 없습니다.") + String orderName, + @NotNull(message = "member_id는 null일 수 없습니다.") + Long memberId, + @JsonDeserialize(using = ObjectToStringDeserializer.class) + @NotBlank(message = "metadata는 비어있거나 공백일 수 없습니다.") + String metadata, + @NotNull(message = "service_type은 null일 수 없습니다.") + ServiceType serviceType +) { + + public TosspaymentsConfirmRequest toTosspaymentsConfirmRequest() { + return new TosspaymentsConfirmRequest(paymentKey, orderId, totalAmount); + } + + public TosspaymentsPayment toTosspaymentsPayment() { + return new TosspaymentsPayment(paymentKey, memberId, orderId, orderName, totalAmount, metadata, serviceType); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/EasyPay.java b/src/main/java/com/samhap/kokomen/payment/service/dto/EasyPay.java new file mode 100644 index 00000000..76ac5eef --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/EasyPay.java @@ -0,0 +1,16 @@ +package com.samhap.kokomen.payment.service.dto; + +public record EasyPay( + String provider, + Long amount, + Long discountAmount +) { + + public static EasyPay from(com.samhap.kokomen.payment.external.dto.EasyPay easyPay) { + return new EasyPay( + easyPay.provider(), + easyPay.amount(), + easyPay.discountAmount() + ); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/Failure.java b/src/main/java/com/samhap/kokomen/payment/service/dto/Failure.java new file mode 100644 index 00000000..6d43f105 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/Failure.java @@ -0,0 +1,11 @@ +package com.samhap.kokomen.payment.service.dto; + +public record Failure( + String code, + String message +) { + + public static Failure from(com.samhap.kokomen.payment.external.dto.Failure failure) { + return new Failure(failure.code(), failure.message()); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/PaymentResponse.java b/src/main/java/com/samhap/kokomen/payment/service/dto/PaymentResponse.java new file mode 100644 index 00000000..190640f6 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/PaymentResponse.java @@ -0,0 +1,74 @@ +package com.samhap.kokomen.payment.service.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.samhap.kokomen.global.infrastructure.ObjectToStringDeserializer; +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +import com.samhap.kokomen.payment.external.dto.TossDateTimeDeserializer; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import java.time.LocalDateTime; +import java.util.List; + +public record PaymentResponse( + String paymentKey, + PaymentType type, + String orderId, + String orderName, + String mId, + String currency, + String method, + Long totalAmount, + Long balanceAmount, + TosspaymentsStatus status, + @JsonDeserialize(using = TossDateTimeDeserializer.class) + LocalDateTime requestedAt, + @JsonDeserialize(using = TossDateTimeDeserializer.class) + LocalDateTime approvedAt, + String lastTransactionKey, + Long suppliedAmount, + Long vat, + Long taxFreeAmount, + Long taxExemptionAmount, + boolean isPartialCancelable, + @JsonDeserialize(using = ObjectToStringDeserializer.class) + String metadata, + Receipt receipt, + Checkout checkout, + EasyPay easyPay, + String country, + Failure failure, + List cancels +) { + + public static PaymentResponse from(TosspaymentsPaymentResponse response) { + return new PaymentResponse( + response.paymentKey(), + response.type(), + response.orderId(), + response.orderName(), + response.mId(), + response.currency(), + response.method(), + response.totalAmount(), + response.balanceAmount(), + response.status(), + response.requestedAt(), + response.approvedAt(), + response.lastTransactionKey(), + response.suppliedAmount(), + response.vat(), + response.taxFreeAmount(), + response.taxExemptionAmount(), + response.isPartialCancelable(), + response.metadata(), + response.receipt() != null ? Receipt.from(response.receipt()) : null, + response.checkout() != null ? Checkout.from(response.checkout()) : null, + response.easyPay() != null ? EasyPay.from(response.easyPay()) : null, + response.country(), + response.failure() != null ? Failure.from(response.failure()) : null, + response.cancels() != null ? response.cancels().stream() + .map(TosspaymentsCancel::from) + .toList() : null + ); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/Receipt.java b/src/main/java/com/samhap/kokomen/payment/service/dto/Receipt.java new file mode 100644 index 00000000..975278b1 --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/Receipt.java @@ -0,0 +1,10 @@ +package com.samhap.kokomen.payment.service.dto; + +public record Receipt( + String url +) { + + public static Receipt from(com.samhap.kokomen.payment.external.dto.Receipt receipt) { + return new Receipt(receipt.url()); + } +} diff --git a/src/main/java/com/samhap/kokomen/payment/service/dto/TosspaymentsCancel.java b/src/main/java/com/samhap/kokomen/payment/service/dto/TosspaymentsCancel.java new file mode 100644 index 00000000..6cbe303e --- /dev/null +++ b/src/main/java/com/samhap/kokomen/payment/service/dto/TosspaymentsCancel.java @@ -0,0 +1,37 @@ +package com.samhap.kokomen.payment.service.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.samhap.kokomen.payment.external.dto.TossDateTimeDeserializer; +import java.time.LocalDateTime; + +public record TosspaymentsCancel( + String transactionKey, + String cancelReason, + Long taxExemptionAmount, + @JsonDeserialize(using = TossDateTimeDeserializer.class) + LocalDateTime canceledAt, + Long easyPayDiscountAmount, + String receiptKey, + Long cancelAmount, + Long taxFreeAmount, + Long refundableAmount, + String cancelStatus, + String cancelRequestId +) { + + public static TosspaymentsCancel from(com.samhap.kokomen.payment.external.dto.TosspaymentsCancel cancel) { + return new TosspaymentsCancel( + cancel.transactionKey(), + cancel.cancelReason(), + cancel.taxExemptionAmount(), + cancel.canceledAt(), + cancel.easyPayDiscountAmount(), + cancel.receiptKey(), + cancel.cancelAmount(), + cancel.taxFreeAmount(), + cancel.refundableAmount(), + cancel.cancelStatus(), + cancel.cancelRequestId() + ); + } +} diff --git a/src/main/java/com/samhap/kokomen/recruit/schedular/service/RecruitmentApiClient.java b/src/main/java/com/samhap/kokomen/recruit/schedular/service/RecruitmentApiClient.java index 573a01b2..a4adaf7b 100644 --- a/src/main/java/com/samhap/kokomen/recruit/schedular/service/RecruitmentApiClient.java +++ b/src/main/java/com/samhap/kokomen/recruit/schedular/service/RecruitmentApiClient.java @@ -1,5 +1,6 @@ package com.samhap.kokomen.recruit.schedular.service; +import com.samhap.kokomen.global.exception.BadRequestException; import com.samhap.kokomen.recruit.schedular.dto.ApiResponse; import com.samhap.kokomen.recruit.schedular.dto.PagedData; import com.samhap.kokomen.recruit.schedular.dto.RecruitmentDto; @@ -108,13 +109,13 @@ private ApiResponse> executeApiRequest(URI uri) { private void validateResponse(ApiResponse> response, int page) { if (response == null) { - throw new IllegalStateException("API 응답이 null입니다 (페이지: " + page + ")"); + throw new BadRequestException("API 응답이 null입니다 (페이지: " + page + ")"); } if (response.getData() == null) { - throw new IllegalStateException("API 응답 데이터가 null입니다 (페이지: " + page + ")"); + throw new BadRequestException("API 응답 데이터가 null입니다 (페이지: " + page + ")"); } if (response.getData().getContent() == null) { - throw new IllegalStateException("API 응답 컨텐츠가 null입니다 (페이지: " + page + ")"); + throw new BadRequestException("API 응답 컨텐츠가 null입니다 (페이지: " + page + ")"); } if (!Boolean.TRUE.equals(response.getSuccess())) { log.error("API 응답 실패 (페이지: {}): {}", page, response); diff --git a/src/main/java/com/samhap/kokomen/token/domain/Token.java b/src/main/java/com/samhap/kokomen/token/domain/Token.java index db858020..50463809 100644 --- a/src/main/java/com/samhap/kokomen/token/domain/Token.java +++ b/src/main/java/com/samhap/kokomen/token/domain/Token.java @@ -48,7 +48,7 @@ public Token(Long memberId, TokenType type, Integer tokenCount) { public void addTokens(int count) { if (count < 0) { - throw new IllegalStateException("추가할 토큰 수는 0보다 커야 합니다."); + throw new BadRequestException("추가할 토큰 수는 0보다 커야 합니다."); } this.tokenCount += count; } @@ -62,7 +62,7 @@ public void useToken() { public void useTokens(int count) { if (count <= 0) { - throw new IllegalArgumentException("사용할 토큰 수는 0보다 커야 합니다."); + throw new BadRequestException("사용할 토큰 수는 0보다 커야 합니다."); } if (this.tokenCount < count) { throw new BadRequestException("사용할 수 있는 토큰이 부족합니다."); @@ -80,7 +80,7 @@ public boolean hasEnoughTokens(int requiredCount) { public void setTokenCount(int count) { if (count < 0) { - throw new IllegalStateException("토큰 수는 0보다 작을 수 없습니다."); + throw new BadRequestException("토큰 수는 0보다 작을 수 없습니다."); } this.tokenCount = count; } 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 49eb955d..f84d34f8 100644 --- a/src/main/java/com/samhap/kokomen/token/domain/TokenPurchase.java +++ b/src/main/java/com/samhap/kokomen/token/domain/TokenPurchase.java @@ -1,6 +1,7 @@ package com.samhap.kokomen.token.domain; import com.samhap.kokomen.global.domain.BaseEntity; +import com.samhap.kokomen.global.exception.BadRequestException; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -97,7 +98,8 @@ public TokenPurchase(Long memberId, String paymentKey, String orderId, Long tota public TokenPurchase(Long memberId, String paymentKey, String orderId, Long totalAmount, String orderName, String productName, Integer purchaseCount, Long unitPrice, - Integer remainingCount, TokenPurchaseState state, String paymentMethod, String easyPayProvider) { + Integer remainingCount, TokenPurchaseState state, String paymentMethod, + String easyPayProvider) { this.memberId = memberId; this.paymentKey = paymentKey; this.orderId = orderId; @@ -114,7 +116,7 @@ public TokenPurchase(Long memberId, String paymentKey, String orderId, Long tota public void useToken() { if (!hasRemainingTokens()) { - throw new IllegalStateException("사용할 수 있는 토큰이 없습니다."); + throw new BadRequestException("사용할 수 있는 토큰이 없습니다."); } this.remainingCount--; @@ -128,10 +130,10 @@ public void useToken() { public int useTokens(int count) { if (count <= 0) { - throw new IllegalArgumentException("사용할 토큰 수는 0보다 커야 합니다."); + throw new BadRequestException("사용할 토큰 수는 0보다 커야 합니다."); } if (!hasRemainingTokens()) { - throw new IllegalStateException("사용할 수 있는 토큰이 없습니다."); + throw new BadRequestException("사용할 수 있는 토큰이 없습니다."); } int tokensToUse = Math.min(count, this.remainingCount); @@ -152,15 +154,15 @@ public boolean hasRemainingTokens() { public void refund(RefundReasonCode refundReasonCode, String refundReasonText) { if (!isRefundable()) { - throw new IllegalStateException("환불 불가능한 상태입니다."); + throw new BadRequestException("환불 불가능한 상태입니다."); } if (refundReasonCode == null) { - throw new IllegalArgumentException("환불 사유 코드는 필수입니다."); + throw new BadRequestException("환불 사유 코드는 필수입니다."); } if (refundReasonCode.requiresReasonText() && (refundReasonText == null || refundReasonText.trim().isEmpty())) { - throw new IllegalArgumentException("기타 환불 사유일 때는 상세한 사유를 입력해야 합니다."); + throw new BadRequestException("기타 환불 사유일 때는 상세한 사유를 입력해야 합니다."); } this.state = TokenPurchaseState.REFUNDED; diff --git a/src/main/java/com/samhap/kokomen/token/dto/ConfirmRequest.java b/src/main/java/com/samhap/kokomen/token/dto/ConfirmRequest.java deleted file mode 100644 index f5225e31..00000000 --- a/src/main/java/com/samhap/kokomen/token/dto/ConfirmRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.samhap.kokomen.token.dto; - -public record ConfirmRequest( - String paymentKey, - String orderId, - Long totalAmount, - String orderName, - Long memberId, - PurchaseMetadata metadata, - String serviceType -) { -} diff --git a/src/main/java/com/samhap/kokomen/token/dto/PaymentResponse.java b/src/main/java/com/samhap/kokomen/token/dto/PaymentResponse.java deleted file mode 100644 index 8eae1b5c..00000000 --- a/src/main/java/com/samhap/kokomen/token/dto/PaymentResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.samhap.kokomen.token.dto; - -public record PaymentResponse( - String method, - EasyPay easyPay -) { - public record EasyPay( - String provider - ) { - } -} \ No newline at end of file diff --git a/src/main/java/com/samhap/kokomen/token/dto/RefundRequest.java b/src/main/java/com/samhap/kokomen/token/dto/RefundRequest.java deleted file mode 100644 index b2471069..00000000 --- a/src/main/java/com/samhap/kokomen/token/dto/RefundRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.samhap.kokomen.token.dto; - -public record RefundRequest( - String paymentKey, - String cancelReason -) { -} diff --git a/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseRequest.java b/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseRequest.java index cff738c3..7d381ca9 100644 --- a/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseRequest.java +++ b/src/main/java/com/samhap/kokomen/token/dto/TokenPurchaseRequest.java @@ -1,5 +1,11 @@ package com.samhap.kokomen.token.dto; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.exception.InternalServerErrorException; +import com.samhap.kokomen.payment.domain.ServiceType; +import com.samhap.kokomen.payment.service.dto.ConfirmRequest; import com.samhap.kokomen.product.domain.TokenProduct; import com.samhap.kokomen.token.domain.TokenPurchase; import jakarta.validation.constraints.NotBlank; @@ -20,27 +26,34 @@ public record TokenPurchaseRequest( String productName ) { - public ConfirmRequest toConfirmRequest(Long memberId) { - TokenProduct product = TokenProduct.valueOf(productName); + public ConfirmRequest toPaymentConfirmRequest(Long memberId, ObjectMapper objectMapper) { + TokenProduct product = readTokenProduct(productName); PurchaseMetadata metadata = new PurchaseMetadata( productName, getTokenCountFromProduct(product), product.getUnitPrice() ); + String metadataJson; + try { + metadataJson = objectMapper.writeValueAsString(metadata); + } catch (JsonProcessingException e) { + throw new InternalServerErrorException("metadata 직렬화 중 오류가 발생했습니다.", e); + } + return new ConfirmRequest( paymentKey, orderId, price, orderName, memberId, - metadata, - "INTERVIEW" + metadataJson, + ServiceType.INTERVIEW ); } - public TokenPurchase toTokenPurchase(Long memberId, PaymentResponse paymentResponse) { - TokenProduct product = TokenProduct.valueOf(productName); + public TokenPurchase toTokenPurchase(Long memberId, String paymentMethod, String easyPayProvider) { + TokenProduct product = readTokenProduct(productName); return TokenPurchase.builder() .memberId(memberId) .paymentKey(paymentKey) @@ -50,12 +63,20 @@ public TokenPurchase toTokenPurchase(Long memberId, PaymentResponse paymentRespo .productName(productName) .purchaseCount(getTokenCountFromProduct(product)) .unitPrice(product.getUnitPrice()) - .paymentMethod(paymentResponse.method()) - .easyPayProvider(paymentResponse.easyPay() != null ? paymentResponse.easyPay().provider() : null) + .paymentMethod(paymentMethod) + .easyPayProvider(easyPayProvider) .build(); } private int getTokenCountFromProduct(TokenProduct product) { return product.getTokenCount(); } + + private static TokenProduct readTokenProduct(String productName) { + try { + return TokenProduct.valueOf(productName); + } catch (IllegalArgumentException e) { + throw new BadRequestException("유효하지 않은 product_name 입니다."); + } + } } diff --git a/src/main/java/com/samhap/kokomen/token/external/PaymentClient.java b/src/main/java/com/samhap/kokomen/token/external/PaymentClient.java deleted file mode 100644 index 4591a56b..00000000 --- a/src/main/java/com/samhap/kokomen/token/external/PaymentClient.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.samhap.kokomen.token.external; - -import com.samhap.kokomen.global.annotation.ExecutionTimer; -import com.samhap.kokomen.global.exception.InternalApiException; -import com.samhap.kokomen.token.dto.ConfirmRequest; -import com.samhap.kokomen.token.dto.PaymentResponse; -import com.samhap.kokomen.token.dto.RefundRequest; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; -import org.springframework.web.client.RestClientResponseException; - -@Slf4j -@ExecutionTimer -@Component -public class PaymentClient { - - private final RestClient restClient; - - public PaymentClient(PaymentClientBuilder paymentClientBuilder) { - this.restClient = paymentClientBuilder.getPaymentClientBuilder().build(); - } - - public PaymentResponse confirmPayment(ConfirmRequest confirmRequest) { - try { - return restClient.post() - .uri("/internal/v1/payments/confirm") - .body(confirmRequest) - .retrieve() - .body(PaymentResponse.class); - } catch (RestClientResponseException e) { - log.error("[Payment API 오류] confirmPayment - 상태 코드: {}, 응답: {}", e.getStatusCode(), e.getResponseBodyAsString(), e); - throw new InternalApiException("Payment API 서버로부터 오류 응답을 받았습니다. 상태 코드: " + e.getStatusCode(), e); - } catch (Exception e) { - log.error("[Payment API 오류] confirmPayment - 예상치 못한 오류", e); - throw new InternalApiException("Payment API 호출 중 예상치 못한 오류가 발생했습니다.", e); - } - } - - public void refundPayment(RefundRequest refundRequest) { - try { - restClient.post() - .uri("/internal/v1/payments/cancel") - .body(refundRequest) - .retrieve() - .toBodilessEntity(); - } catch (RestClientResponseException e) { - log.error("[Payment API 오류] refundPayment - 상태 코드: {}, 응답: {}", e.getStatusCode(), e.getResponseBodyAsString(), e); - throw new InternalApiException("Payment API 서버로부터 오류 응답을 받았습니다. 상태 코드: " + e.getStatusCode(), e); - } catch (Exception e) { - log.error("[Payment API 오류] refundPayment - 예상치 못한 오류", e); - throw new InternalApiException("Payment API 호출 중 예상치 못한 오류가 발생했습니다.", e); - } - } -} diff --git a/src/main/java/com/samhap/kokomen/token/external/PaymentClientBuilder.java b/src/main/java/com/samhap/kokomen/token/external/PaymentClientBuilder.java deleted file mode 100644 index 47eea34c..00000000 --- a/src/main/java/com/samhap/kokomen/token/external/PaymentClientBuilder.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.samhap.kokomen.token.external; - -import com.fasterxml.jackson.databind.ObjectMapper; -import java.time.Duration; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import org.slf4j.MDC; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; - -@Slf4j -@Getter -@Component -public class PaymentClientBuilder { - - private final RestClient.Builder paymentClientBuilder; - - public PaymentClientBuilder( - RestClient.Builder builder, - ObjectMapper objectMapper, - @Value("${payment.base-url}") String paymentBaseUrl, - @Value("${payment.connect-timeout}") Duration connectTimeout, - @Value("${payment.read-timeout}") Duration readTimeout - ) { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(connectTimeout); - requestFactory.setReadTimeout(readTimeout); - - this.paymentClientBuilder = builder - .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .requestInterceptor((request, body, execution) -> { - String requestId = MDC.get("requestId"); - if (requestId != null) { - request.getHeaders().add("X-RequestID", requestId); - } - return execution.execute(request, body); - }) - .requestFactory(requestFactory) - .baseUrl(paymentBaseUrl) - .messageConverters(converters -> { - converters.removeIf(MappingJackson2HttpMessageConverter.class::isInstance); - converters.add(new MappingJackson2HttpMessageConverter(objectMapper)); - }); - } -} 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 dd16d7a8..dc080a05 100644 --- a/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java +++ b/src/main/java/com/samhap/kokomen/token/service/TokenFacadeService.java @@ -1,18 +1,20 @@ package com.samhap.kokomen.token.service; +import com.fasterxml.jackson.databind.ObjectMapper; import com.samhap.kokomen.global.annotation.DistributedLock; import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.payment.service.PaymentFacadeService; +import com.samhap.kokomen.payment.service.dto.CancelRequest; +import com.samhap.kokomen.payment.service.dto.ConfirmRequest; +import com.samhap.kokomen.payment.service.dto.PaymentResponse; import com.samhap.kokomen.product.domain.TokenProduct; import com.samhap.kokomen.token.domain.RefundReasonCode; import com.samhap.kokomen.token.domain.TokenPurchase; import com.samhap.kokomen.token.domain.TokenPurchaseState; -import com.samhap.kokomen.token.dto.PaymentResponse; -import com.samhap.kokomen.token.dto.RefundRequest; import com.samhap.kokomen.token.dto.TokenPurchaseRequest; import com.samhap.kokomen.token.dto.TokenPurchaseResponse; import com.samhap.kokomen.token.dto.TokenPurchaseResponses; import com.samhap.kokomen.token.dto.TokenRefundRequest; -import com.samhap.kokomen.token.external.PaymentClient; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,7 +31,8 @@ public class TokenFacadeService { private final TokenService tokenService; private final TokenPurchaseService tokenPurchaseService; - private final PaymentClient paymentClient; + private final PaymentFacadeService paymentFacadeService; + private final ObjectMapper objectMapper; @DistributedLock(prefix = "token", key = "#memberId") @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -41,8 +44,11 @@ public void purchaseTokens(Long memberId, TokenPurchaseRequest request) { log.info("토큰 구매 요청 - memberId: {}, paymentKey: {}, tokenCount: {}, amount: {}", memberId, request.paymentKey(), tokenCount, totalAmount); - PaymentResponse paymentResponse = paymentClient.confirmPayment(request.toConfirmRequest(memberId)); - tokenPurchaseService.saveTokenPurchase(request.toTokenPurchase(memberId, paymentResponse)); + ConfirmRequest confirmRequest = request.toPaymentConfirmRequest(memberId, objectMapper); + PaymentResponse paymentResponse = paymentFacadeService.confirmPayment(confirmRequest); + tokenPurchaseService.saveTokenPurchase( + request.toTokenPurchase(memberId, paymentResponse.method(), + paymentResponse.easyPay() != null ? paymentResponse.easyPay().provider() : null)); tokenService.addPaidTokens(memberId, tokenCount); log.info("토큰 구매 완료 - memberId: {}, paymentKey: {}, 증가된 토큰: {}", memberId, request.paymentKey(), tokenCount); @@ -134,7 +140,7 @@ public void refundTokens(Long memberId, Long tokenPurchaseId, TokenRefundRequest "토큰 환불 요청 - memberId: {}, tokenPurchaseId: {}, paymentKey: {}, refundTokenCount: {}, refundReasonCode: {}, refundReasonText: {}", memberId, tokenPurchaseId, paymentKey, refundTokenCount, refundReasonCode, refundReasonText); - paymentClient.refundPayment(new RefundRequest(paymentKey, refundReasonText)); + paymentFacadeService.cancelPayment(new CancelRequest(paymentKey, refundReasonText)); tokenPurchaseService.refundTokenPurchase(tokenPurchase, refundReasonCode, request.refundReasonText()); tokenService.refundPaidTokenCount(memberId, refundTokenCount); diff --git a/src/main/java/com/samhap/kokomen/token/service/TokenService.java b/src/main/java/com/samhap/kokomen/token/service/TokenService.java index 266e533e..42f2b367 100644 --- a/src/main/java/com/samhap/kokomen/token/service/TokenService.java +++ b/src/main/java/com/samhap/kokomen/token/service/TokenService.java @@ -1,6 +1,7 @@ package com.samhap.kokomen.token.service; import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.exception.InternalServerErrorException; import com.samhap.kokomen.token.domain.Token; import com.samhap.kokomen.token.domain.TokenType; import com.samhap.kokomen.token.repository.TokenRepository; @@ -30,7 +31,7 @@ public void createTokensForNewMember(Long memberId) { public void addPaidTokens(Long memberId, int count) { int updatedRows = tokenRepository.incrementTokenCountModifying(memberId, TokenType.PAID, count); if (updatedRows == 0) { - throw new IllegalStateException("유료 토큰 구매에 실패했습니다. memberId: " + memberId); + throw new InternalServerErrorException("유료 토큰 구매에 실패했습니다. memberId: " + memberId); } } @@ -68,7 +69,7 @@ public void usePaidTokens(Long memberId, int count) { public void refundPaidTokenCount(Long memberId, int count) { int updatedRows = tokenRepository.decrementTokenCountModifying(memberId, TokenType.PAID, count); if (updatedRows == 0) { - throw new IllegalStateException("유료 토큰 환불에 실패했습니다. memberId: " + memberId); + throw new InternalServerErrorException("유료 토큰 환불에 실패했습니다. memberId: " + memberId); } } @@ -101,6 +102,6 @@ public int readPaidTokenCount(Long memberId) { @Transactional(readOnly = true) public Token readTokenByMemberIdAndType(Long memberId, TokenType type) { return tokenRepository.findByMemberIdAndType(memberId, type) - .orElseThrow(() -> new IllegalStateException("해당 유형의 토큰이 존재하지 않습니다. type: " + type)); + .orElseThrow(() -> new BadRequestException("해당 유형의 토큰이 존재하지 않습니다. type: " + type)); } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 70ccf458..2a2a7f74 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -14,13 +14,17 @@ server: cookie: domain: kokomen.kr secure: false -payment: - base-url: http://kokomen-payment-dev-internal:8080 - connect-timeout: 3s - read-timeout: 10s aws: root-question-s3-path: dev/root-question-voice/ next-question-s3-path: dev/next-question-voice/ resume-s3-path: dev/resume/ portfolio-s3-path: dev/portfolio/ company-s3-path: recruit/dev/company/ +tosspayments: + widget-secret-key: ${WIDGET_SECRET_KEY_DEV} +retry: + tosspayments: + max-attempts: 3 + initial-interval: 500 + multiplier: 2.0 + max-interval: 2000 diff --git a/src/main/resources/application-load-test.yml b/src/main/resources/application-load-test.yml index cf590498..07b1e458 100644 --- a/src/main/resources/application-load-test.yml +++ b/src/main/resources/application-load-test.yml @@ -14,13 +14,17 @@ server: cookie: domain: kokomen.kr secure: true -payment: - base-url: ${PAYMENT_BASE_URL_LOAD_TEST} - connect-timeout: 3s - read-timeout: 3s aws: root-question-s3-path: dev/root-question-voice/ next-question-s3-path: dev/next-question-voice/ resume-s3-path: dev/resume/ portfolio-s3-path: dev/portfolio/ company-s3-path: recruit/dev/company/ +tosspayments: + widget-secret-key: ${WIDGET_SECRET_KEY_PROD} +retry: + tosspayments: + max-attempts: 3 + initial-interval: 500 + multiplier: 2.0 + max-interval: 2000 diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c6e53d52..6a5bb4e8 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -13,13 +13,17 @@ server: session: cookie: secure: true -payment: - base-url: http://kokomen-payment-local-internal:8080 - connect-timeout: 3s - read-timeout: 3s aws: root-question-s3-path: local/root-question-voice/ next-question-s3-path: local/next-question-voice/ resume-s3-path: local/resume/ portfolio-s3-path: local/portfolio/ company-s3-path: recruit/local/company/ +tosspayments: + widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 +retry: + tosspayments: + max-attempts: 3 + initial-interval: 500 + multiplier: 2.0 + max-interval: 2000 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 4c2b5837..cb1ef7fe 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -14,13 +14,17 @@ server: cookie: domain: kokomen.kr secure: true -payment: - base-url: ${PAYMENT_BASE_URL_PROD} - connect-timeout: 3s - read-timeout: 10s aws: root-question-s3-path: prod/root-question-voice/ next-question-s3-path: prod/next-question-voice/ resume-s3-path: prod/resume/ portfolio-s3-path: prod/portfolio/ company-s3-path: recruit/prod/company/ +tosspayments: + widget-secret-key: ${WIDGET_SECRET_KEY_PROD} +retry: + tosspayments: + max-attempts: 3 + initial-interval: 500 + multiplier: 2.0 + max-interval: 2000 diff --git a/src/main/resources/db/migration/V39__create_tosspayments_payment_tables.sql b/src/main/resources/db/migration/V39__create_tosspayments_payment_tables.sql new file mode 100644 index 00000000..ffd64bc9 --- /dev/null +++ b/src/main/resources/db/migration/V39__create_tosspayments_payment_tables.sql @@ -0,0 +1,55 @@ +CREATE TABLE tosspayments_payment ( + id BIGINT NOT NULL AUTO_INCREMENT, + payment_key VARCHAR(200) NOT NULL, + member_id BIGINT NOT NULL, + order_id VARCHAR(100) NOT NULL, + order_name VARCHAR(200) NOT NULL, + total_amount BIGINT NOT NULL, + metadata JSON NOT NULL, + state ENUM('NEED_APPROVE', 'APPROVED', 'NOT_NEED_CANCEL', 'NEED_CANCEL', + 'CONNECTION_TIMEOUT', 'CANCELED', 'CLIENT_BAD_REQUEST', + 'SERVER_BAD_REQUEST', 'COMPLETED') NOT NULL, + service_type ENUM('INTERVIEW') NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_payment_payment_key (payment_key), + UNIQUE KEY uk_payment_order_id (order_id), + KEY idx_payment_member_id (member_id) +); + +CREATE TABLE tosspayments_payment_result ( + id BIGINT NOT NULL AUTO_INCREMENT, + tosspayments_payment_id BIGINT NOT NULL, + type ENUM('NORMAL', 'BILLING', 'BRANDPAY') NOT NULL, + m_id VARCHAR(200) NOT NULL, + currency VARCHAR(100) NOT NULL, + method VARCHAR(100), + total_amount BIGINT NOT NULL, + balance_amount BIGINT NOT NULL, + tosspayments_status ENUM('READY', 'IN_PROGRESS', 'WAITING_FOR_DEPOSIT', 'DONE', + 'CANCELED', 'PARTIAL_CANCELED', 'ABORTED', 'EXPIRED') NOT NULL, + requested_at DATETIME(6) NOT NULL, + approved_at DATETIME(6), + last_transaction_key VARCHAR(200), + supplied_amount BIGINT, + vat BIGINT, + tax_free_amount BIGINT, + tax_exemption_amount BIGINT, + is_partial_cancelable BOOLEAN NOT NULL DEFAULT FALSE, + receipt_url VARCHAR(500), + easy_pay_provider VARCHAR(50), + easy_pay_amount BIGINT, + easy_pay_discount_amount BIGINT, + country VARCHAR(10), + failure_code VARCHAR(100), + failure_message TEXT, + cancel_reason VARCHAR(200) DEFAULT NULL, + canceled_at DATETIME DEFAULT NULL, + cancel_status VARCHAR(50) DEFAULT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_payment_result_payment + FOREIGN KEY (tosspayments_payment_id) REFERENCES tosspayments_payment(id) +); diff --git a/src/main/resources/db/migration/V40__alter_canceled_at_to_datetime6.sql b/src/main/resources/db/migration/V40__alter_canceled_at_to_datetime6.sql new file mode 100644 index 00000000..d9b04b46 --- /dev/null +++ b/src/main/resources/db/migration/V40__alter_canceled_at_to_datetime6.sql @@ -0,0 +1 @@ +ALTER TABLE tosspayments_payment_result MODIFY COLUMN canceled_at DATETIME(6); diff --git a/src/test/java/com/samhap/kokomen/global/BaseTest.java b/src/test/java/com/samhap/kokomen/global/BaseTest.java index 8ddf3ff0..147c4ea9 100644 --- a/src/test/java/com/samhap/kokomen/global/BaseTest.java +++ b/src/test/java/com/samhap/kokomen/global/BaseTest.java @@ -9,7 +9,7 @@ import com.samhap.kokomen.interview.external.SupertoneClient; import com.samhap.kokomen.interview.service.QuestionGenerationAsyncService; import com.samhap.kokomen.resume.external.ResumeEvaluationGptClient; -import com.samhap.kokomen.token.external.PaymentClient; +import com.samhap.kokomen.payment.external.TosspaymentsClient; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -33,7 +33,7 @@ public abstract class BaseTest { @MockitoBean protected S3Client s3Client; @MockitoBean - protected PaymentClient paymentClient; + protected TosspaymentsClient tosspaymentsClient; @MockitoBean protected InterviewProceedGptClient interviewProceedGptClient; @MockitoBean diff --git a/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentFixtureBuilder.java b/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentFixtureBuilder.java new file mode 100644 index 00000000..56fd8249 --- /dev/null +++ b/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentFixtureBuilder.java @@ -0,0 +1,66 @@ +package com.samhap.kokomen.global.fixture.payment; + +import com.samhap.kokomen.payment.domain.ServiceType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; + +public class TosspaymentsPaymentFixtureBuilder { + + private String paymentKey; + private Long memberId; + private String orderId; + private String orderName; + private Long totalAmount; + private String metadata; + private ServiceType serviceType; + + public static TosspaymentsPaymentFixtureBuilder builder() { + return new TosspaymentsPaymentFixtureBuilder(); + } + + public TosspaymentsPaymentFixtureBuilder paymentKey(String paymentKey) { + this.paymentKey = paymentKey; + return this; + } + + public TosspaymentsPaymentFixtureBuilder memberId(Long memberId) { + this.memberId = memberId; + return this; + } + + public TosspaymentsPaymentFixtureBuilder orderId(String orderId) { + this.orderId = orderId; + return this; + } + + public TosspaymentsPaymentFixtureBuilder orderName(String orderName) { + this.orderName = orderName; + return this; + } + + public TosspaymentsPaymentFixtureBuilder totalAmount(Long totalAmount) { + this.totalAmount = totalAmount; + return this; + } + + public TosspaymentsPaymentFixtureBuilder metadata(String metadata) { + this.metadata = metadata; + return this; + } + + public TosspaymentsPaymentFixtureBuilder serviceType(ServiceType serviceType) { + this.serviceType = serviceType; + return this; + } + + public TosspaymentsPayment build() { + return new TosspaymentsPayment( + paymentKey != null ? paymentKey : "test_payment_key_123", + memberId != null ? memberId : 1L, + orderId != null ? orderId : "order_123", + orderName != null ? orderName : "테스트 주문", + totalAmount != null ? totalAmount : 10000L, + metadata != null ? metadata : "{\"test\": \"metadata\"}", + serviceType != null ? serviceType : ServiceType.INTERVIEW + ); + } +} diff --git a/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentResultFixtureBuilder.java b/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentResultFixtureBuilder.java new file mode 100644 index 00000000..9dbaf155 --- /dev/null +++ b/src/test/java/com/samhap/kokomen/global/fixture/payment/TosspaymentsPaymentResultFixtureBuilder.java @@ -0,0 +1,106 @@ +package com.samhap.kokomen.global.fixture.payment; + +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +import java.time.LocalDateTime; + +public class TosspaymentsPaymentResultFixtureBuilder { + + private TosspaymentsPayment tosspaymentsPayment; + private PaymentType type; + private String mId; + private String currency; + private Long totalAmount; + private String method; + private Long balanceAmount; + private TosspaymentsStatus tosspaymentsStatus; + private LocalDateTime requestedAt; + private LocalDateTime approvedAt; + private String lastTransactionKey; + private Long suppliedAmount; + private Long vat; + private Long taxFreeAmount; + private Long taxExemptionAmount; + private boolean isPartialCancelable; + private String receiptUrl; + private String easyPayProvider; + private Long easyPayAmount; + private Long easyPayDiscountAmount; + private String country; + private String failureCode; + private String failureMessage; + + public static TosspaymentsPaymentResultFixtureBuilder builder() { + return new TosspaymentsPaymentResultFixtureBuilder(); + } + + public TosspaymentsPaymentResultFixtureBuilder tosspaymentsPayment(TosspaymentsPayment tosspaymentsPayment) { + this.tosspaymentsPayment = tosspaymentsPayment; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder type(PaymentType type) { + this.type = type; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder method(String method) { + this.method = method; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder tosspaymentsStatus(TosspaymentsStatus status) { + this.tosspaymentsStatus = status; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder approvedAt(LocalDateTime approvedAt) { + this.approvedAt = approvedAt; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder receiptUrl(String receiptUrl) { + this.receiptUrl = receiptUrl; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder failureCode(String failureCode) { + this.failureCode = failureCode; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder failureMessage(String failureMessage) { + this.failureMessage = failureMessage; + return this; + } + + public TosspaymentsPaymentResult build() { + return TosspaymentsPaymentResult.builder() + .tosspaymentsPayment(tosspaymentsPayment) + .type(type != null ? type : PaymentType.NORMAL) + .mId(mId != null ? mId : "tvivarepublica") + .currency(currency != null ? currency : "KRW") + .totalAmount(totalAmount != null ? totalAmount : 10000L) + .method(method != null ? method : "카드") + .balanceAmount(balanceAmount != null ? balanceAmount : 10000L) + .tosspaymentsStatus(tosspaymentsStatus != null ? tosspaymentsStatus : TosspaymentsStatus.DONE) + .requestedAt(requestedAt != null ? requestedAt : LocalDateTime.of(2024, 1, 1, 12, 0)) + .approvedAt(approvedAt) + .lastTransactionKey(lastTransactionKey != null ? lastTransactionKey : "test_transaction_key") + .suppliedAmount(suppliedAmount != null ? suppliedAmount : 9091L) + .vat(vat != null ? vat : 909L) + .taxFreeAmount(taxFreeAmount != null ? taxFreeAmount : 0L) + .taxExemptionAmount(taxExemptionAmount != null ? taxExemptionAmount : 0L) + .isPartialCancelable(isPartialCancelable) + .receiptUrl(receiptUrl) + .easyPayProvider(easyPayProvider) + .easyPayAmount(easyPayAmount) + .easyPayDiscountAmount(easyPayDiscountAmount) + .country(country != null ? country : "KR") + .failureCode(failureCode) + .failureMessage(failureMessage) + .build(); + } +} diff --git a/src/test/java/com/samhap/kokomen/interview/domain/QuestionAndAnswersTest.java b/src/test/java/com/samhap/kokomen/interview/domain/QuestionAndAnswersTest.java index ef6fb86b..28afdea0 100644 --- a/src/test/java/com/samhap/kokomen/interview/domain/QuestionAndAnswersTest.java +++ b/src/test/java/com/samhap/kokomen/interview/domain/QuestionAndAnswersTest.java @@ -112,7 +112,7 @@ class QuestionAndAnswersTest { curAnswerContent, questions.get(questions.size() - 1).getId(), interview - )).isInstanceOf(IllegalArgumentException.class) + )).isInstanceOf(BadRequestException.class) .hasMessageContaining("질문과 답변의 개수가 일치하지 않습니다."); } diff --git a/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResultTest.java b/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResultTest.java new file mode 100644 index 00000000..0319b7c2 --- /dev/null +++ b/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResultTest.java @@ -0,0 +1,42 @@ +package com.samhap.kokomen.payment.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; + +class TosspaymentsPaymentResultTest { + + @Test + void 취소_정보를_업데이트할_수_있다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + TosspaymentsPaymentResult result = new TosspaymentsPaymentResult( + payment, PaymentType.NORMAL, "tvivarepublica", "KRW", 10000L, "카드", + 10000L, TosspaymentsStatus.DONE, LocalDateTime.now(), LocalDateTime.now(), + "transaction_key", 9091L, 909L, 0L, 0L, true, + null, null, null, null, "KR", null, null + ); + + String cancelReason = "단순 변심"; + LocalDateTime canceledAt = LocalDateTime.of(2025, 1, 1, 12, 0); + Long easyPayDiscountAmount = 0L; + String lastTransactionKey = "cancel_transaction_key"; + String cancelStatus = "DONE"; + TosspaymentsStatus tosspaymentsStatus = TosspaymentsStatus.CANCELED; + + result.updateCancelInfo(cancelReason, canceledAt, easyPayDiscountAmount, + lastTransactionKey, cancelStatus, tosspaymentsStatus); + + assertAll( + () -> assertThat(result.getCancelReason()).isEqualTo(cancelReason), + () -> assertThat(result.getCanceledAt()).isEqualTo(canceledAt), + () -> assertThat(result.getEasyPayDiscountAmount()).isEqualTo(easyPayDiscountAmount), + () -> assertThat(result.getLastTransactionKey()).isEqualTo(lastTransactionKey), + () -> assertThat(result.getCancelStatus()).isEqualTo(cancelStatus), + () -> assertThat(result.getTosspaymentsStatus()).isEqualTo(tosspaymentsStatus) + ); + } +} diff --git a/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java b/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java new file mode 100644 index 00000000..bc13de3a --- /dev/null +++ b/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java @@ -0,0 +1,67 @@ +package com.samhap.kokomen.payment.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.samhap.kokomen.global.exception.InternalServerErrorException; +import org.junit.jupiter.api.Test; + +class TosspaymentsPaymentTest { + + @Test + void 토스페이먼츠_응답_검증에_성공한다() { + String paymentKey = "payment_key"; + String orderId = "order_id"; + long totalAmount = 10000L; + TosspaymentsPayment payment = new TosspaymentsPayment( + paymentKey, 1L, orderId, "주문명", totalAmount, "{}", ServiceType.INTERVIEW + ); + + assertDoesNotThrow(() -> payment.validateTosspaymentsResult(paymentKey, orderId, totalAmount)); + } + + @Test + void paymentKey가_다르면_검증에_실패한다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + + assertThatThrownBy(() -> payment.validateTosspaymentsResult("wrong_key", "order_id", 10000L)) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentErrorMessage.PAYMENT_KEY_MISMATCH.getMessage()); + } + + @Test + void orderId가_다르면_검증에_실패한다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + + assertThatThrownBy(() -> payment.validateTosspaymentsResult("payment_key", "wrong_order", 10000L)) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentErrorMessage.ORDER_ID_MISMATCH.getMessage()); + } + + @Test + void totalAmount가_다르면_검증에_실패한다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + + assertThatThrownBy(() -> payment.validateTosspaymentsResult("payment_key", "order_id", 99999L)) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentErrorMessage.TOTAL_AMOUNT_MISMATCH.getMessage()); + } + + @Test + void 결제_상태를_변경할_수_있다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + + payment.updateState(PaymentState.APPROVED); + + assertThat(payment.getState()).isEqualTo(PaymentState.APPROVED); + } +} diff --git a/src/test/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepositoryTest.java b/src/test/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepositoryTest.java new file mode 100644 index 00000000..2df61b84 --- /dev/null +++ b/src/test/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepositoryTest.java @@ -0,0 +1,52 @@ +package com.samhap.kokomen.payment.repository; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.samhap.kokomen.global.BaseTest; +import com.samhap.kokomen.global.fixture.payment.TosspaymentsPaymentFixtureBuilder; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; + +class TosspaymentsPaymentRepositoryTest extends BaseTest { + + @Autowired + private TosspaymentsPaymentRepository tosspaymentsPaymentRepository; + + @Test + void 중복된_paymentKey로_저장하면_예외가_발생한다() { + String duplicateKey = "duplicate_key"; + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey(duplicateKey) + .orderId("order_1") + .build(); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPayment duplicatePayment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey(duplicateKey) + .orderId("order_2") + .build(); + + assertThatThrownBy(() -> tosspaymentsPaymentRepository.save(duplicatePayment)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void 중복된_orderId로_저장하면_예외가_발생한다() { + String duplicateOrder = "duplicate_order"; + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("key_1") + .orderId(duplicateOrder) + .build(); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPayment duplicatePayment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("key_2") + .orderId(duplicateOrder) + .build(); + + assertThatThrownBy(() -> tosspaymentsPaymentRepository.save(duplicatePayment)) + .isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java b/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java new file mode 100644 index 00000000..a105ef4f --- /dev/null +++ b/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java @@ -0,0 +1,382 @@ +package com.samhap.kokomen.payment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.samhap.kokomen.global.BaseTest; +import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.exception.InternalServerErrorException; +import com.samhap.kokomen.global.exception.PaymentServiceErrorMessage; +import com.samhap.kokomen.global.fixture.payment.TosspaymentsPaymentFixtureBuilder; +import com.samhap.kokomen.global.fixture.payment.TosspaymentsPaymentResultFixtureBuilder; +import com.samhap.kokomen.payment.domain.PaymentState; +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.ServiceType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +import com.samhap.kokomen.payment.external.dto.Failure; +import com.samhap.kokomen.payment.external.dto.TosspaymentsCancel; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentRepository; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentResultRepository; +import com.samhap.kokomen.payment.service.dto.CancelRequest; +import com.samhap.kokomen.payment.service.dto.ConfirmRequest; +import com.samhap.kokomen.payment.service.dto.PaymentResponse; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; + +class PaymentFacadeServiceTest extends BaseTest { + + @Autowired + private PaymentFacadeService paymentFacadeService; + + @Autowired + private TosspaymentsPaymentRepository tosspaymentsPaymentRepository; + + @Autowired + private TosspaymentsPaymentResultRepository tosspaymentsPaymentResultRepository; + + @Test + void 결제_승인에_성공한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any(), any())).thenReturn(createSuccessResponse()); + + PaymentResponse response = paymentFacadeService.confirmPayment(request); + + assertThat(response.paymentKey()).isEqualTo("payment_key"); + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.COMPLETED); + assertThat(tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(payment.getId())).isPresent(); + } + + @Test + void 서버_원인_400_에러가_발생하면_SERVER_BAD_REQUEST_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getStatusCode()).thenReturn(HttpStatus.BAD_REQUEST); + when(clientError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("INVALID_API_KEY", "잘못된 API 키입니다.")); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage()); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.SERVER_BAD_REQUEST); + } + + @Test + void 클라이언트_원인_400_에러가_발생하면_CLIENT_BAD_REQUEST_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getStatusCode()).thenReturn(HttpStatus.BAD_REQUEST); + when(clientError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("INVALID_CARD_NUMBER", "카드 번호가 유효하지 않습니다.")); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(BadRequestException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.CLIENT_BAD_REQUEST); + } + + @Test + void 결제_승인_시_5xx_에러_응답_파싱에_성공하면_결과를_저장하고_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpServerErrorException serverError = mock(HttpServerErrorException.class); + when(serverError.getResponseBodyAs(TosspaymentsPaymentResponse.class)) + .thenReturn(createSuccessResponse()); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(serverError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(HttpServerErrorException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + assertThat(tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(payment.getId())).isPresent(); + } + + @Test + void 결제_승인_시_5xx_에러_응답_파싱에_실패하면_NEED_CANCEL_상태만_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpServerErrorException serverError = mock(HttpServerErrorException.class); + when(serverError.getResponseBodyAs(TosspaymentsPaymentResponse.class)) + .thenThrow(new RuntimeException("파싱 실패")); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(serverError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(HttpServerErrorException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + assertThat(tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(payment.getId())).isEmpty(); + } + + @Test + void 결제_승인_시_연결_타임아웃이_발생하면_CONNECTION_TIMEOUT_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(new ResourceAccessException("I/O error", new SocketTimeoutException("Connect timed out"))); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(ResourceAccessException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.CONNECTION_TIMEOUT); + } + + @Test + void 결제_승인_시_읽기_타임아웃이_발생하면_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(new ResourceAccessException("I/O error", new SocketTimeoutException("Read timed out"))); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(ResourceAccessException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + } + + @Test + void 결제_승인_시_SocketTimeoutException_외_네트워크_오류가_발생하면_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(new ResourceAccessException("I/O error", new ConnectException("Connection refused"))); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(ResourceAccessException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + } + + @Test + void 결제_승인_시_400_에러_응답_파싱에_실패하면_InternalServerErrorException을_던진다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getStatusCode()).thenReturn(HttpStatus.BAD_REQUEST); + when(clientError.getResponseBodyAs(Failure.class)).thenReturn(null); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage()); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.SERVER_BAD_REQUEST); + } + + @Test + void 결제_승인_시_예상치_못한_예외가_발생하면_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(new RuntimeException("예상치 못한 오류")); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(RuntimeException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + } + + @Test + void 결제_승인_시_5xx_에러_후_재시도에_성공하면_COMPLETED_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpServerErrorException serverError = mock(HttpServerErrorException.class); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(serverError) + .thenReturn(createSuccessResponse()); + + PaymentResponse response = paymentFacadeService.confirmPayment(request); + + assertThat(response.paymentKey()).isEqualTo("payment_key"); + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.COMPLETED); + verify(tosspaymentsClient, times(2)).confirmPayment(any(), any()); + } + + @Test + void 결제_승인_시_연결_타임아웃_후_재시도에_성공하면_COMPLETED_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(new ResourceAccessException("I/O error", new SocketTimeoutException("Connect timed out"))) + .thenReturn(createSuccessResponse()); + + PaymentResponse response = paymentFacadeService.confirmPayment(request); + + assertThat(response.paymentKey()).isEqualTo("payment_key"); + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.COMPLETED); + verify(tosspaymentsClient, times(2)).confirmPayment(any(), any()); + } + + @Test + void 결제_승인_시_409_에러_후_재시도에_성공하면_COMPLETED_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException conflictError = mock(HttpClientErrorException.class); + when(conflictError.getStatusCode()).thenReturn(HttpStatus.CONFLICT); + when(tosspaymentsClient.confirmPayment(any(), any())) + .thenThrow(conflictError) + .thenReturn(createSuccessResponse()); + + PaymentResponse response = paymentFacadeService.confirmPayment(request); + + assertThat(response.paymentKey()).isEqualTo("payment_key"); + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.COMPLETED); + verify(tosspaymentsClient, times(2)).confirmPayment(any(), any()); + } + + @Test + void 결제_승인_시_클라이언트_400_에러는_재시도하지_않는다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getStatusCode()).thenReturn(HttpStatus.BAD_REQUEST); + when(clientError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("INVALID_CARD_NUMBER", "카드 번호가 유효하지 않습니다.")); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(BadRequestException.class); + + verify(tosspaymentsClient, times(1)).confirmPayment(any(), any()); + } + + @Test + void 결제_승인_시_409_재시도_소진_후_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException conflictError = mock(HttpClientErrorException.class); + when(conflictError.getStatusCode()).thenReturn(HttpStatus.CONFLICT); + when(conflictError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("IDEMPOTENT_REQUEST_PROCESSING", "이전 요청이 처리 중입니다.")); + when(tosspaymentsClient.confirmPayment(any(), any())).thenThrow(conflictError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(InternalServerErrorException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + verify(tosspaymentsClient, times(3)).confirmPayment(any(), any()); + } + + @Test + void 결제_취소에_성공한다() { + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("payment_key") + .build(); + payment.updateState(PaymentState.COMPLETED); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPaymentResult paymentResult = TosspaymentsPaymentResultFixtureBuilder.builder() + .tosspaymentsPayment(payment) + .build(); + tosspaymentsPaymentResultRepository.save(paymentResult); + + LocalDateTime canceledAt = LocalDateTime.of(2025, 1, 1, 12, 0); + TosspaymentsCancel cancel = new TosspaymentsCancel( + "cancel_tx_key", "단순 변심", 0L, + canceledAt, 0L, null, 10000L, 0L, 10000L, "DONE", null + ); + TosspaymentsPaymentResponse cancelResponse = new TosspaymentsPaymentResponse( + "payment_key", PaymentType.NORMAL, "order_id", "주문명", + "tvivarepublica", "KRW", "카드", 10000L, 10000L, + TosspaymentsStatus.CANCELED, LocalDateTime.now(), LocalDateTime.now(), + "cancel_tx_key", 9091L, 909L, 0L, 0L, true, + "{}", null, null, null, "KR", null, List.of(cancel) + ); + when(tosspaymentsClient.cancelPayment(any(), any())).thenReturn(cancelResponse); + + paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심")); + + TosspaymentsPayment updatedPayment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key") + .orElseThrow(); + assertThat(updatedPayment.getState()).isEqualTo(PaymentState.CANCELED); + TosspaymentsPaymentResult updatedResult = tosspaymentsPaymentResultRepository + .findByTosspaymentsPaymentId(updatedPayment.getId()).orElseThrow(); + assertThat(updatedResult.getCancelReason()).isEqualTo("단순 변심"); + assertThat(updatedResult.getCanceledAt()).isEqualTo(canceledAt); + assertThat(updatedResult.getCancelStatus()).isEqualTo("DONE"); + } + + @Test + void 결제_취소_시_400_에러가_발생하면_BadRequestException을_던진다() { + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("ALREADY_CANCELED_PAYMENT", "이미 취소된 결제입니다.")); + when(tosspaymentsClient.cancelPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(BadRequestException.class); + } + + @Test + void 결제_취소_시_400_에러_응답_파싱에_실패하면_InternalServerErrorException을_던진다() { + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getResponseBodyAs(Failure.class)).thenReturn(null); + when(tosspaymentsClient.cancelPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage()); + } + + @Test + void 결제_취소_시_5xx_에러가_발생하면_InternalServerErrorException을_던진다() { + when(tosspaymentsClient.cancelPayment(any(), any())) + .thenThrow(new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR)); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage()); + } + + @Test + void 결제_취소_시_네트워크_에러가_발생하면_InternalServerErrorException을_던진다() { + when(tosspaymentsClient.cancelPayment(any(), any())) + .thenThrow(new ResourceAccessException("네트워크 오류")); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CANCEL_NETWORK_ERROR.getMessage()); + } + + @Test + void 결제_취소_시_예상치_못한_예외가_발생하면_InternalServerErrorException을_던진다() { + when(tosspaymentsClient.cancelPayment(any(), any())) + .thenThrow(new RuntimeException("예상치 못한 오류")); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage()); + } + + private ConfirmRequest createConfirmRequest() { + return new ConfirmRequest("payment_key", "order_id", 10000L, "주문명", 1L, "{}", ServiceType.INTERVIEW); + } + + private TosspaymentsPaymentResponse createSuccessResponse() { + return new TosspaymentsPaymentResponse( + "payment_key", PaymentType.NORMAL, "order_id", "주문명", + "tvivarepublica", "KRW", "카드", 10000L, 10000L, + TosspaymentsStatus.DONE, LocalDateTime.now(), LocalDateTime.now(), + "transaction_key", 9091L, 909L, 0L, 0L, true, + "{}", null, null, null, "KR", null, null + ); + } +} diff --git a/src/test/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionServiceTest.java b/src/test/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionServiceTest.java new file mode 100644 index 00000000..8a4ac224 --- /dev/null +++ b/src/test/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionServiceTest.java @@ -0,0 +1,101 @@ +package com.samhap.kokomen.payment.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.samhap.kokomen.global.BaseTest; +import com.samhap.kokomen.global.fixture.payment.TosspaymentsPaymentFixtureBuilder; +import com.samhap.kokomen.global.fixture.payment.TosspaymentsPaymentResultFixtureBuilder; +import com.samhap.kokomen.payment.domain.PaymentState; +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +import com.samhap.kokomen.payment.external.dto.TosspaymentsCancel; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentRepository; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentResultRepository; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class TosspaymentsTransactionServiceTest extends BaseTest { + + @Autowired + private TosspaymentsTransactionService tosspaymentsTransactionService; + + @Autowired + private TosspaymentsPaymentRepository tosspaymentsPaymentRepository; + + @Autowired + private TosspaymentsPaymentResultRepository tosspaymentsPaymentResultRepository; + + @Test + void 취소_결과에_취소_정보가_있으면_결제_결과에_취소_정보를_업데이트한다() { + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("payment_key") + .build(); + payment.updateState(PaymentState.COMPLETED); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPaymentResult result = TosspaymentsPaymentResultFixtureBuilder.builder() + .tosspaymentsPayment(payment) + .build(); + tosspaymentsPaymentResultRepository.save(result); + + LocalDateTime canceledAt = LocalDateTime.of(2025, 1, 1, 12, 0); + TosspaymentsCancel cancel = new TosspaymentsCancel( + "cancel_tx_key", "단순 변심", 0L, + canceledAt, 0L, null, 10000L, 0L, 10000L, "DONE", null + ); + TosspaymentsPaymentResponse response = new TosspaymentsPaymentResponse( + "payment_key", PaymentType.NORMAL, "order_id", "주문명", + "tvivarepublica", "KRW", "카드", 10000L, 10000L, + TosspaymentsStatus.CANCELED, LocalDateTime.now(), LocalDateTime.now(), + "cancel_tx_key", 9091L, 909L, 0L, 0L, true, + "{}", null, null, null, "KR", null, List.of(cancel) + ); + + tosspaymentsTransactionService.applyCancelResult(response); + + TosspaymentsPayment updatedPayment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key") + .orElseThrow(); + assertThat(updatedPayment.getState()).isEqualTo(PaymentState.CANCELED); + TosspaymentsPaymentResult updatedResult = tosspaymentsPaymentResultRepository + .findByTosspaymentsPaymentId(updatedPayment.getId()).orElseThrow(); + assertThat(updatedResult.getCancelReason()).isEqualTo("단순 변심"); + assertThat(updatedResult.getCanceledAt()).isEqualTo(canceledAt); + assertThat(updatedResult.getCancelStatus()).isEqualTo("DONE"); + } + + @Test + void 취소_결과에_취소_정보가_없으면_결제_상태만_변경한다() { + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("payment_key") + .build(); + payment.updateState(PaymentState.COMPLETED); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPaymentResult result = TosspaymentsPaymentResultFixtureBuilder.builder() + .tosspaymentsPayment(payment) + .build(); + tosspaymentsPaymentResultRepository.save(result); + + TosspaymentsPaymentResponse response = new TosspaymentsPaymentResponse( + "payment_key", PaymentType.NORMAL, "order_id", "주문명", + "tvivarepublica", "KRW", "카드", 10000L, 10000L, + TosspaymentsStatus.CANCELED, LocalDateTime.now(), LocalDateTime.now(), + "tx_key", 9091L, 909L, 0L, 0L, true, + "{}", null, null, null, "KR", null, null + ); + + tosspaymentsTransactionService.applyCancelResult(response); + + TosspaymentsPayment updatedPayment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key") + .orElseThrow(); + assertThat(updatedPayment.getState()).isEqualTo(PaymentState.CANCELED); + TosspaymentsPaymentResult updatedResult = tosspaymentsPaymentResultRepository + .findByTosspaymentsPaymentId(updatedPayment.getId()).orElseThrow(); + assertThat(updatedResult.getCancelReason()).isNull(); + } +} 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 76405ffb..cf07d565 100644 --- a/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java +++ b/src/test/java/com/samhap/kokomen/token/controller/TokenControllerTest.java @@ -29,8 +29,8 @@ import com.samhap.kokomen.token.domain.RefundReasonCode; import com.samhap.kokomen.token.domain.TokenPurchase; import com.samhap.kokomen.token.domain.TokenPurchaseState; -import com.samhap.kokomen.token.dto.PaymentResponse; -import com.samhap.kokomen.token.dto.PaymentResponse.EasyPay; +import com.samhap.kokomen.payment.service.dto.PaymentResponse; +import com.samhap.kokomen.payment.service.PaymentFacadeService; import com.samhap.kokomen.token.dto.TokenPurchaseRequest; import com.samhap.kokomen.token.dto.TokenRefundRequest; import com.samhap.kokomen.token.repository.TokenPurchaseRepository; @@ -39,10 +39,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.restdocs.payload.JsonFieldType; class TokenControllerTest extends BaseControllerTest { + @MockitoBean + private PaymentFacadeService paymentFacadeService; @Autowired private ObjectMapper objectMapper; @Autowired @@ -103,7 +106,12 @@ class TokenControllerTest extends BaseControllerTest { "토큰 10개", "TOKEN_10" ); - given(paymentClient.confirmPayment(any())).willReturn(new PaymentResponse("간편결제", new EasyPay("카카오페이"))); + given(paymentFacadeService.confirmPayment(any())).willReturn(new PaymentResponse( + "test_paymentKey", null, "test_orderId", "토큰 10개", "mId", "KRW", + "간편결제", 500L, 500L, null, null, null, null, null, null, null, null, + false, null, null, null, + new com.samhap.kokomen.payment.service.dto.EasyPay("카카오페이", null, null), + null, null, null)); long initialPaidTokens = tokenService.readPaidTokenCount(member.getId()); // when & then @@ -541,7 +549,7 @@ class TokenControllerTest extends BaseControllerTest { session.setAttribute("MEMBER_ID", member.getId()); TokenRefundRequest request = new TokenRefundRequest(RefundReasonCode.CHANGE_OF_MIND, null); - willDoNothing().given(paymentClient).refundPayment(any()); + willDoNothing().given(paymentFacadeService).cancelPayment(any()); long initialPaidTokens = tokenService.readPaidTokenCount(member.getId()); diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index b4d982df..404fab48 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -22,10 +22,14 @@ oauth: client-id: test_google_client_id client-secret: test_google_client_secret -payment: - base-url: http://kokomen-payment-test - connect-timeout: 3s - read-timeout: 3s +tosspayments: + widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 +retry: + tosspayments: + max-attempts: 3 + initial-interval: 500 + multiplier: 2.0 + max-interval: 2000 supertone: api-token: mock-token