diff --git a/backend/build.gradle b/backend/build.gradle index 8fcfd58d2..4ecad186d 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -116,6 +116,10 @@ sourceSets { main.java.srcDirs += [generated] } +test { + maxParallelForks = 1 +} + // gradle clean 시에 QClass 디렉토리 삭제 clean { delete file(generated) diff --git a/backend/src/main/java/com/backend/domain/reservation/controller/ReservationController.java b/backend/src/main/java/com/backend/domain/reservation/controller/ReservationController.java index 2910a839b..1f296edca 100644 --- a/backend/src/main/java/com/backend/domain/reservation/controller/ReservationController.java +++ b/backend/src/main/java/com/backend/domain/reservation/controller/ReservationController.java @@ -5,6 +5,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -20,6 +21,7 @@ import com.backend.global.dto.request.GlobalRequest; import com.backend.global.dto.response.GenericResponse; import com.backend.global.dto.response.ScrollResponse; +import com.backend.global.payment.dto.request.TossPaymentRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -36,6 +38,29 @@ public class ReservationController { private final ReservationService reservationService; + @PostMapping("/prepare") + @Operation(summary = "예약 준비 - 주문서 생성", description = "결제를 위한 예약 주문서 생성") + public ResponseEntity> prepareReservation( + @RequestBody @Valid final ReservationRequest.Reserve requestDto, + @AuthenticationPrincipal final CustomOAuth2User user) { + + ReservationResponse.Detail response = reservationService.prepareReservation(requestDto, user.getId()); + + return ResponseEntity.created(URI.create("/api/v1/reservations/" + response.reservationId())) + .body(GenericResponse.of(true, response)); + } + + @GetMapping("/confirm") + @Operation(summary = "결제 승인 및 예약 확정", description = "Toss 결제 성공 후 결제 승인 API 호출") + public ResponseEntity> confirmReservationPayment( + @ModelAttribute final TossPaymentRequest request, + @AuthenticationPrincipal final CustomOAuth2User user) { + + reservationService.confirmReservationPayment(request, user.getId()); + + return ResponseEntity.ok(GenericResponse.of(true)); + } + @PostMapping @Operation(summary = "예약 신청 및 생성", description = "유저가 선상 낚시를 예약 할 때 사용하는 API") public ResponseEntity> saveReservation( diff --git a/backend/src/main/java/com/backend/domain/reservation/dto/response/ReservationResponse.java b/backend/src/main/java/com/backend/domain/reservation/dto/response/ReservationResponse.java index 3cf0be542..b8f74245d 100644 --- a/backend/src/main/java/com/backend/domain/reservation/dto/response/ReservationResponse.java +++ b/backend/src/main/java/com/backend/domain/reservation/dto/response/ReservationResponse.java @@ -129,6 +129,21 @@ public record DetailWithName( ) { } + /** + * + * @param reservationId + * @param shipFishingPostId + * @param reservationNumber + * @param subject + * @param reservationDate + * @param startTime + * @param location + * @param guestCount + * @param totalPrice + * @param reservationStatus + * @param fileIdList + * @param createdAt + */ @Builder public record DetailQueryDto( Long reservationId, @@ -149,6 +164,21 @@ public record DetailQueryDto( } } + /** + * + * @param reservationId + * @param shipFishingPostId + * @param reservationNumber + * @param subject + * @param reservationDate + * @param startTime + * @param location + * @param guestCount + * @param totalPrice + * @param reservationStatus + * @param fileUrlList + * @param createdAt + */ @Builder public record DetailReservationList( Long reservationId, diff --git a/backend/src/main/java/com/backend/domain/reservation/entity/Reservation.java b/backend/src/main/java/com/backend/domain/reservation/entity/Reservation.java index ca1ed2818..04aff3588 100644 --- a/backend/src/main/java/com/backend/domain/reservation/entity/Reservation.java +++ b/backend/src/main/java/com/backend/domain/reservation/entity/Reservation.java @@ -1,8 +1,10 @@ package com.backend.domain.reservation.entity; import java.time.LocalDate; +import java.time.OffsetDateTime; import com.backend.global.baseentity.BaseEntity; +import com.backend.global.payment.dto.response.TossPaymentResponse; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -58,7 +60,28 @@ public class Reservation extends BaseEntity { @Builder.Default @Enumerated(EnumType.STRING) @Column(nullable = false) - private ReservationStatus status = ReservationStatus.CONFIRMED; + private ReservationStatus status = ReservationStatus.PENDING; + + @Column(length = 100) + private String paymentKey; + + @Column(length = 20) + private String paymentMethod; + + @Column(length = 50) + private String cardNumber; + + @Column(length = 20) + private String cardApproveNo; + + @Column(length = 1024) + private String receiptUrl; + + @Column + private Long totalAmount; + + @Column + private OffsetDateTime approvedAt; public void updatePending(final Boolean isSuccess) { this.status = isSuccess ? ReservationStatus.CONFIRMED : ReservationStatus.REJECTED; @@ -67,4 +90,18 @@ public void updatePending(final Boolean isSuccess) { public void updateCanceled() { this.status = this.status == ReservationStatus.CONFIRMED ? ReservationStatus.CANCELLED : this.status; } + + public void updateTossPaymentInfo(final TossPaymentResponse response) { + this.paymentKey = response.getPaymentKey(); + this.approvedAt = response.getApprovedAt(); + this.paymentMethod = response.getMethod(); + this.totalAmount = response.getTotalAmount(); + if (response.getCard() != null) { + this.cardNumber = response.getCard().getNumber(); + this.cardApproveNo = response.getCard().getApproveNo(); + } + if (response.getReceipt() != null) { + this.receiptUrl = response.getReceipt().getUrl(); + } + } } diff --git a/backend/src/main/java/com/backend/domain/reservation/exception/ReservationErrorCode.java b/backend/src/main/java/com/backend/domain/reservation/exception/ReservationErrorCode.java index 8dc672e22..cbd60f3cf 100644 --- a/backend/src/main/java/com/backend/domain/reservation/exception/ReservationErrorCode.java +++ b/backend/src/main/java/com/backend/domain/reservation/exception/ReservationErrorCode.java @@ -15,7 +15,8 @@ public enum ReservationErrorCode implements ErrorCode { WRONG_PRICE_VALUE(HttpStatus.BAD_REQUEST, 11002, "예약 금액이 일치하지 않습니다."), NOT_AVAILABLE_DATE_RESERVATION(HttpStatus.CONFLICT, 11003, "예약 가능한 일자가 아닙니다."), NOT_AVAILABLE_END_RESERVATION(HttpStatus.CONFLICT, 11004, "예약 정원 초과 입니다."), - NOT_AUTHORITY_RESERVATION(HttpStatus.FORBIDDEN, 11005, "해당 예약에 대한 권한이 없습니다."); + NOT_AUTHORITY_RESERVATION(HttpStatus.FORBIDDEN, 11005, "해당 예약에 대한 권한이 없습니다."), + ALREADY_CONFIRMED_RESERVATION(HttpStatus.BAD_REQUEST, 11006, "해당 예약은 이미 완료되었습니다."); private final HttpStatus httpStatus; private final Integer code; diff --git a/backend/src/main/java/com/backend/domain/reservation/repository/ReservationJpaRepository.java b/backend/src/main/java/com/backend/domain/reservation/repository/ReservationJpaRepository.java index 25a27dae3..df009b3b9 100644 --- a/backend/src/main/java/com/backend/domain/reservation/repository/ReservationJpaRepository.java +++ b/backend/src/main/java/com/backend/domain/reservation/repository/ReservationJpaRepository.java @@ -1,5 +1,7 @@ package com.backend.domain.reservation.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import com.backend.domain.reservation.entity.Reservation; @@ -9,4 +11,5 @@ public interface ReservationJpaRepository extends JpaRepository findByReservationNumber(final String reservationNumber); } diff --git a/backend/src/main/java/com/backend/domain/reservation/repository/ReservationRepository.java b/backend/src/main/java/com/backend/domain/reservation/repository/ReservationRepository.java index 3e2ea63ba..ff17caa00 100644 --- a/backend/src/main/java/com/backend/domain/reservation/repository/ReservationRepository.java +++ b/backend/src/main/java/com/backend/domain/reservation/repository/ReservationRepository.java @@ -28,6 +28,15 @@ public interface ReservationRepository { */ Optional findById(final Long reservationId); + /** + * 예약 번호로 예약 정보 조회 메서드 + * + * @param reservationNumber 예약번호 + * @return 예약 데이터 + * @implSpec 예약 번호와 일치하는 예약 정보를 조회합니다. + */ + Optional findByReservationNumber(final String reservationNumber); + /** * 유저의 예약 내역 횟수를 조회합니다. * diff --git a/backend/src/main/java/com/backend/domain/reservation/repository/ReservationRepositoryImpl.java b/backend/src/main/java/com/backend/domain/reservation/repository/ReservationRepositoryImpl.java index 2a7faa6dd..c1083beaa 100644 --- a/backend/src/main/java/com/backend/domain/reservation/repository/ReservationRepositoryImpl.java +++ b/backend/src/main/java/com/backend/domain/reservation/repository/ReservationRepositoryImpl.java @@ -32,6 +32,12 @@ public Optional findById(final Long reservationId) { return reservationJpaRepository.findById(reservationId); } + @Override + public Optional findByReservationNumber(final String reservationNumber) { + + return reservationJpaRepository.findByReservationNumber(reservationNumber); + } + @Override public Long getReservationCount(final Long memberId) { diff --git a/backend/src/main/java/com/backend/domain/reservation/service/ReservationService.java b/backend/src/main/java/com/backend/domain/reservation/service/ReservationService.java index 90dbd7145..907d325d9 100644 --- a/backend/src/main/java/com/backend/domain/reservation/service/ReservationService.java +++ b/backend/src/main/java/com/backend/domain/reservation/service/ReservationService.java @@ -4,9 +4,26 @@ import com.backend.domain.reservation.dto.response.ReservationResponse; import com.backend.global.dto.request.GlobalRequest; import com.backend.global.dto.response.ScrollResponse; +import com.backend.global.payment.dto.request.TossPaymentRequest; public interface ReservationService { + /** + * 예약 전에 주문서를 생성하는 메서드 + * + * @param requestDto 예약 정보 + * @param memberId 유저 ID + * @return 주문서 정보 + */ + ReservationResponse.Detail prepareReservation(final ReservationRequest.Reserve requestDto, final Long memberId); + + /** + * 결제 요청 후 재고 차감 여부로 결제 메서드 + * + * @param requestDto 토스 결제 정보 + */ + void confirmReservationPayment(final TossPaymentRequest requestDto, final Long memberId); + /** * 예약을 생성하는 메서드 * diff --git a/backend/src/main/java/com/backend/domain/reservation/service/ReservationServiceImpl.java b/backend/src/main/java/com/backend/domain/reservation/service/ReservationServiceImpl.java index 689620331..ffa7c9bb5 100644 --- a/backend/src/main/java/com/backend/domain/reservation/service/ReservationServiceImpl.java +++ b/backend/src/main/java/com/backend/domain/reservation/service/ReservationServiceImpl.java @@ -10,6 +10,7 @@ import com.backend.domain.reservation.dto.request.ReservationRequest; import com.backend.domain.reservation.dto.response.ReservationResponse; import com.backend.domain.reservation.entity.Reservation; +import com.backend.domain.reservation.entity.ReservationStatus; import com.backend.domain.reservation.exception.ReservationErrorCode; import com.backend.domain.reservation.exception.ReservationException; import com.backend.domain.reservation.repository.ReservationRepository; @@ -21,6 +22,9 @@ import com.backend.domain.shipfishingpost.repository.ShipFishingPostRepository; import com.backend.global.dto.request.GlobalRequest; import com.backend.global.dto.response.ScrollResponse; +import com.backend.global.payment.TossPaymentHttpClient; +import com.backend.global.payment.dto.request.TossPaymentRequest; +import com.backend.global.payment.dto.response.TossPaymentResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,6 +39,56 @@ public class ReservationServiceImpl implements ReservationService { private final ReservationRepository reservationRepository; private final ReservationDateRepository reservationDateRepository; private final ShipFishingPostRepository shipFishingPostRepository; + private final TossPaymentHttpClient tossPaymentHttpClient; + + @Override + @Transactional + public ReservationResponse.Detail prepareReservation( + final ReservationRequest.Reserve requestDto, + final Long memberId) { + + verifyTodayAfterDate(requestDto.reservationDate()); + + ShipFishingPost shipFishingPost = getShipFishingPostEntity(requestDto.shipFishingPostId()); + + verifyPriceValue(requestDto.price(), requestDto.totalPrice(), shipFishingPost.getPrice(), + shipFishingPost.getPrice() * requestDto.guestCount()); + + ReservationDate reservationDate = getReservationDate(shipFishingPost.getShipFishingPostId(), + requestDto.reservationDate()); + + verifyReservationDate(reservationDate, requestDto.guestCount()); + + Reservation reservation = reservationRepository.save( + ReservationConverter.fromReservationRequest(requestDto, memberId)); + + return ReservationConverter.fromReservationResponseDetail(reservation); + } + + @Override + @Transactional + public void confirmReservationPayment(final TossPaymentRequest requestDto, final Long memberId) { + + Reservation reservation = verifyPayment(requestDto, memberId); + + try { + TossPaymentResponse response = tossPaymentHttpClient.sendPaymentConfirmRequest(requestDto); + + updateReservationDateWithRemainCount(reservation.getShipFishingPostId(), reservation.getReservationDate(), + reservation.getGuestCount(), false); + + reservation.updatePending(true); + reservation.updateTossPaymentInfo(response); + + activityHistoryService.createActivityHistory(reservation); + } catch (ReservationException e) { + tossPaymentHttpClient.cancelPayment(requestDto.paymentKey(), "예약 불가: 재고 부족", requestDto.amount()); + + reservation.updatePending(false); + + throw e; + } + } @Override @Transactional @@ -80,6 +134,7 @@ public ReservationResponse.DetailWithMember getReservation(final Long reservatio } @Override + @Transactional(readOnly = true) public Long getReservationCount(final Long memberId) { return reservationRepository.getReservationCount(memberId); @@ -94,6 +149,7 @@ public ScrollResponse getUserReservationList } @Override + @Transactional(readOnly = true) public ScrollResponse getUserReservationListWithImage( final Long memberId, final Boolean afterToday, @@ -115,6 +171,7 @@ public ScrollResponse getCaptainReservationL } @Override + @Transactional(readOnly = true) public ReservationResponse.DashBoard getDashBoard(final Long memberId, final Integer limitDays) { return reservationRepository.findDashBoardByMemberId(memberId, limitDays); @@ -160,12 +217,25 @@ private ShipFishingPost getShipFishingPostEntity(final Long shipFishingPostId) { .orElseThrow(() -> new ShipFishingPostException(ShipFishingPostErrorCode.POSTS_NOT_FOUND)); } + /** + * 예약 날짜 데이터 조회 메서드 + * + * @param shipFishingPostId 게시글 ID + * @param reservationDate 예약 일자 + * @return 예약 일자 데이터 + */ + private ReservationDate getReservationDate(final Long shipFishingPostId, final LocalDate reservationDate) { + + return reservationDateRepository.findByShipFishingPostIdAndReservationDate(shipFishingPostId, reservationDate) + .orElseThrow(() -> new ReservationException(ReservationErrorCode.NOT_AVAILABLE_DATE_RESERVATION)); + } + /** * 오늘포함 이전 예약 신청, 수정 불가 검증 메서드 * * @param reservationDate 예약 날짜 */ - void verifyTodayAfterDate(final LocalDate reservationDate) { + private void verifyTodayAfterDate(final LocalDate reservationDate) { if (!reservationDate.isAfter(LocalDate.now())) { throw new ReservationException(ReservationErrorCode.NOT_AVAILABLE_DATE_RESERVATION); @@ -229,6 +299,33 @@ private void verifyAuthorization( } } + /** + * 토스 페이먼츠 주문서 검증 메서드 + * + * @param requestDto 토스페이먼츠 데이터 + * @param memberId 유저 ID + * @return {@link Reservation} 예약정보 + */ + private Reservation verifyPayment(final TossPaymentRequest requestDto, final Long memberId) { + + Reservation reservation = reservationRepository.findByReservationNumber(requestDto.orderId()) + .orElseThrow(() -> new ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)); + + if (reservation.getStatus() != ReservationStatus.PENDING) { + throw new ReservationException(ReservationErrorCode.ALREADY_CONFIRMED_RESERVATION); + } + + if (!memberId.equals(reservation.getMemberId())) { + throw new ReservationException(ReservationErrorCode.NOT_AUTHORITY_RESERVATION); + } + + if (!reservation.getTotalPrice().equals(requestDto.amount())) { + throw new ReservationException(ReservationErrorCode.WRONG_PRICE_VALUE); + } + + return reservation; + } + /** * 예약 일자 조회, 예약 가능하면 예약 일자 정보 남은 인원 업데이트 메서드입니다. * diff --git a/backend/src/main/java/com/backend/domain/reservation/util/ReservationUtil.java b/backend/src/main/java/com/backend/domain/reservation/util/ReservationUtil.java index ef19f30b9..3a8f93cab 100644 --- a/backend/src/main/java/com/backend/domain/reservation/util/ReservationUtil.java +++ b/backend/src/main/java/com/backend/domain/reservation/util/ReservationUtil.java @@ -12,7 +12,7 @@ public class ReservationUtil { */ public static String generateReservationNumber() { LocalDate now = LocalDate.now(); - String UUIDNumber = UUID.randomUUID().toString().substring(0, 6); + String UUIDNumber = UUID.randomUUID().toString().substring(0, 8); return String.format("%s-%s", now, UUIDNumber); } diff --git a/backend/src/main/java/com/backend/global/config/WebConfig.java b/backend/src/main/java/com/backend/global/config/WebConfig.java new file mode 100644 index 000000000..322c5ee20 --- /dev/null +++ b/backend/src/main/java/com/backend/global/config/WebConfig.java @@ -0,0 +1,24 @@ +package com.backend.global.config; + +import java.time.Duration; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class WebConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .connectTimeout(Duration.ofSeconds(3)) //서버 연결 시도 최대 시간 + .readTimeout(Duration.ofSeconds(5)) //데이터 응답 대기 최대 시간 + .build(); + } +} diff --git a/backend/src/main/java/com/backend/global/payment/TossPaymentHttpClient.java b/backend/src/main/java/com/backend/global/payment/TossPaymentHttpClient.java new file mode 100644 index 000000000..d7213ce38 --- /dev/null +++ b/backend/src/main/java/com/backend/global/payment/TossPaymentHttpClient.java @@ -0,0 +1,102 @@ +package com.backend.global.payment; + +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +import com.backend.global.payment.dto.request.TossCancelRequest; +import com.backend.global.payment.dto.request.TossPaymentRequest; +import com.backend.global.payment.dto.response.TossCancelResponse; +import com.backend.global.payment.dto.response.TossPaymentResponse; +import com.backend.global.payment.exception.PaymentErrorCode; +import com.backend.global.payment.exception.PaymentException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TossPaymentHttpClient { + + private static final String TOSS_PAYMENT_URL = "https://api.tosspayments.com/v1/payments"; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${api.toss.secret-key}") + private String tossApiSecretKey; + + public TossPaymentResponse sendPaymentConfirmRequest(final TossPaymentRequest request) { + String confirmUrl = TOSS_PAYMENT_URL + "/confirm"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBasicAuth(tossApiSecretKey, ""); + + HttpEntity entity = new HttpEntity<>(request, headers); + + try { + ResponseEntity responseEntity = restTemplate.exchange( + confirmUrl, + HttpMethod.POST, + entity, + TossPaymentResponse.class + ); + return responseEntity.getBody(); + } catch (HttpStatusCodeException e) { + handleApiError(e); + throw new PaymentException(PaymentErrorCode.TOSS_API_ERROR); + } + } + + public TossCancelResponse cancelPayment(final String paymentKey, final String cancelReason, final Long amount) { + String cancelUrl = TOSS_PAYMENT_URL + "/" + paymentKey + "/cancel"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBasicAuth(tossApiSecretKey, ""); + + TossCancelRequest cancelRequest = TossCancelRequest.of(cancelReason, amount); + + HttpEntity entity = new HttpEntity<>(cancelRequest, headers); + + try { + ResponseEntity response = restTemplate.exchange( + cancelUrl, + HttpMethod.POST, + entity, + TossCancelResponse.class + ); + log.info("Toss 결제 취소 완료: paymentKey={}, amount={}", paymentKey, amount); + + return response.getBody(); + } catch (HttpStatusCodeException e) { + log.error("Toss 결제 취소 실패: {}", e.getResponseBodyAsString()); + + throw new PaymentException(PaymentErrorCode.TOSS_API_CANCEL_FAILED); + } + } + + private void handleApiError(HttpStatusCodeException e) { + try { + Map errorResponse = objectMapper + .readValue(e.getResponseBodyAsString(), new TypeReference<>() { + }); + + log.error("Toss API Error: code={}, message={}", errorResponse.get("code"), errorResponse.get("message")); + } catch (Exception ex) { + log.error("Failed to parse Toss API error", ex); + } + } +} diff --git a/backend/src/main/java/com/backend/global/payment/dto/request/TossCancelRequest.java b/backend/src/main/java/com/backend/global/payment/dto/request/TossCancelRequest.java new file mode 100644 index 000000000..72a79095c --- /dev/null +++ b/backend/src/main/java/com/backend/global/payment/dto/request/TossCancelRequest.java @@ -0,0 +1,19 @@ +package com.backend.global.payment.dto.request; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.Builder; + +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public record TossCancelRequest( + String cancelReason, + Long cancelAmount +) { + public static TossCancelRequest of(final String cancelReason, final Long cancelAmount) { + return TossCancelRequest.builder() + .cancelReason(cancelReason) + .cancelAmount(cancelAmount) + .build(); + } +} diff --git a/backend/src/main/java/com/backend/global/payment/dto/request/TossPaymentRequest.java b/backend/src/main/java/com/backend/global/payment/dto/request/TossPaymentRequest.java new file mode 100644 index 000000000..725a8b8b3 --- /dev/null +++ b/backend/src/main/java/com/backend/global/payment/dto/request/TossPaymentRequest.java @@ -0,0 +1,45 @@ +package com.backend.global.payment.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +/** + * 토스 결제 요청 + * + * @param paymentKey + * @param amount + * @param orderId + */ +@Builder +public record TossPaymentRequest( + @NotBlank + @Schema(description = "토스에서 발급하는 결제 고유 식별자") + String paymentKey, + + @NotNull + @Min(0) + @Schema(description = "결제 금액") + Long amount, + + @NotBlank + @Size(min = 6, max = 64) + @Schema(description = "주문번호, 결제 요청에서 직접 생성한 영문 대소문자, 숫자, '-', '_'로 이루어진 6자 이상 64이하 문자열") + String orderId +) { + public static TossPaymentRequest from( + final String paymentKey, + final Long amount, + final String orderId) { + + return TossPaymentRequest.builder() + .paymentKey(paymentKey) + .amount(amount) + .orderId(orderId) + .build(); + } +} + diff --git a/backend/src/main/java/com/backend/global/payment/dto/response/TossCancelResponse.java b/backend/src/main/java/com/backend/global/payment/dto/response/TossCancelResponse.java new file mode 100644 index 000000000..fb17a5496 --- /dev/null +++ b/backend/src/main/java/com/backend/global/payment/dto/response/TossCancelResponse.java @@ -0,0 +1,11 @@ +package com.backend.global.payment.dto.response; + +import java.time.OffsetDateTime; + +public record TossCancelResponse( + String status, + String cancelReason, + Long canceledAmount, + OffsetDateTime canceledAt +) { +} diff --git a/backend/src/main/java/com/backend/global/payment/dto/response/TossPaymentResponse.java b/backend/src/main/java/com/backend/global/payment/dto/response/TossPaymentResponse.java new file mode 100644 index 000000000..82bdd1a20 --- /dev/null +++ b/backend/src/main/java/com/backend/global/payment/dto/response/TossPaymentResponse.java @@ -0,0 +1,28 @@ +package com.backend.global.payment.dto.response; + +import java.time.OffsetDateTime; + +import lombok.Getter; + +@Getter +public class TossPaymentResponse { + private String paymentKey; + private String orderId; + private String method; + private OffsetDateTime approvedAt; + private Long totalAmount; + private String status; + private Card card; + private Receipt receipt; + + @Getter + public static class Card { + private String number; + private String approveNo; + } + + @Getter + public static class Receipt { + private String url; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/backend/global/payment/exception/PaymentErrorCode.java b/backend/src/main/java/com/backend/global/payment/exception/PaymentErrorCode.java new file mode 100644 index 000000000..1a359e0ee --- /dev/null +++ b/backend/src/main/java/com/backend/global/payment/exception/PaymentErrorCode.java @@ -0,0 +1,20 @@ +package com.backend.global.payment.exception; + +import org.springframework.http.HttpStatus; + +import com.backend.global.exception.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PaymentErrorCode implements ErrorCode { + + TOSS_API_ERROR(HttpStatus.BAD_REQUEST, 16001, "결제 오류가 발생했습니다."), + TOSS_API_CANCEL_FAILED(HttpStatus.BAD_REQUEST, 16002, "결제 취소 오류가 발생했습니다."); + + private final HttpStatus httpStatus; + private final Integer code; + private final String message; +} diff --git a/backend/src/main/java/com/backend/global/payment/exception/PaymentException.java b/backend/src/main/java/com/backend/global/payment/exception/PaymentException.java new file mode 100644 index 000000000..4a171ec88 --- /dev/null +++ b/backend/src/main/java/com/backend/global/payment/exception/PaymentException.java @@ -0,0 +1,22 @@ +package com.backend.global.payment.exception; + +import com.backend.global.exception.ErrorCode; +import com.backend.global.exception.GlobalException; + +import lombok.Getter; + +@Getter +public class PaymentException extends GlobalException { + + private final ErrorCode errorCode; + + public PaymentException(final ErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + public PaymentException(final Throwable cause, final ErrorCode errorCode) { + super(cause, errorCode); + this.errorCode = errorCode; + } +} diff --git a/backend/src/test/java/com/backend/domain/reservation/repository/ReservationRepositoryTest.java b/backend/src/test/java/com/backend/domain/reservation/repository/ReservationRepositoryTest.java index 9e1ae23c4..80fdbc7c7 100644 --- a/backend/src/test/java/com/backend/domain/reservation/repository/ReservationRepositoryTest.java +++ b/backend/src/test/java/com/backend/domain/reservation/repository/ReservationRepositoryTest.java @@ -81,6 +81,11 @@ void t01() { Reservation givenReservation = fixtureMonkeyBuilder.giveMeBuilder(Reservation.class) .set("reservationId", null) .set("guestCount", 1) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sample(); // When @@ -109,6 +114,11 @@ void t02() { .set("memberId", savedMember.getMemberId()) .set("reservationDate", LocalDate.of(2025, 1, 1)) .set("guestCount", 1) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sample(); Reservation savedReservation = reservationRepository.save(givenReservation); @@ -156,6 +166,11 @@ void t03() { .set("memberId", memberId) .set("guestCount", i) .set("reservationDate", LocalDate.of(2025, 1, 1).plusDays(i)) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sampleStream() .limit(2) .forEach(reservation -> { @@ -262,6 +277,11 @@ void t04() { .set("guestCount", j) .set("status", ReservationStatus.CONFIRMED) .set("reservationDate", LocalDate.of(2030, 1, 1).plusDays(j)) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sampleStream() .limit(2) .forEach(reservation -> { @@ -349,6 +369,11 @@ void t05() { .set("guestCount", j) .set("status", ReservationStatus.CONFIRMED) .set("reservationDate", LocalDate.of(2030, 1, 1).plusDays(j)) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sampleStream() .limit(2) .forEach(reservation -> { @@ -393,6 +418,11 @@ void t06() { .set("reservationDate", LocalDate.now()) .set("guestCount", 1) .set("status", ReservationStatus.CANCELLED) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sampleStream() .limit(1) .forEach(reservation -> @@ -404,6 +434,11 @@ void t06() { .set("reservationDate", LocalDate.now().plusDays(10)) .set("guestCount", 1) .set("status", ReservationStatus.CANCELLED) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sampleStream() .limit(5) .forEach(reservation -> @@ -415,6 +450,11 @@ void t06() { .set("reservationDate", LocalDate.now().minusDays(10)) .set("guestCount", 1) .set("status", ReservationStatus.CANCELLED) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sampleStream() .limit(5) .forEach(reservation -> @@ -460,6 +500,11 @@ void t07() { .set("totalPrice", 40000L) .set("status", ReservationStatus.CONFIRMED) .set("reservationDate", LocalDate.of(2033, 1, 1).plusDays(i)) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sampleStream() .limit(2) .forEach(reservationRepository::save); @@ -558,6 +603,11 @@ void t08() { .set("totalPrice", 40000L) .set("status", ReservationStatus.CONFIRMED) .set("reservationDate", LocalDate.now().plusDays(i)) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sampleStream() .limit(1) .forEach(reservationRepository::save); @@ -572,6 +622,11 @@ void t08() { .set("totalPrice", 40000L) .set("status", ReservationStatus.CANCELLED) .set("reservationDate", LocalDate.now().plusDays(i)) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sampleStream() .limit(1) .forEach(reservationRepository::save); @@ -631,6 +686,11 @@ void t09() { .set("totalPrice", 40000L) .set("status", ReservationStatus.CONFIRMED) .set("reservationDate", LocalDate.now().plusDays(i)) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sampleStream() .limit(1) .forEach(reservationRepository::save); @@ -645,6 +705,11 @@ void t09() { .set("totalPrice", 40000L) .set("status", ReservationStatus.CANCELLED) .set("reservationDate", LocalDate.now().plusDays(i)) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sampleStream() .limit(1) .forEach(reservationRepository::save); @@ -704,6 +769,11 @@ void t10() { .set("totalPrice", 40000L) .set("status", ReservationStatus.CONFIRMED) .set("reservationDate", LocalDate.now().plusDays(i)) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sampleStream() .limit(1) .forEach(reservationRepository::save); @@ -718,6 +788,11 @@ void t10() { .set("totalPrice", 40000L) .set("status", ReservationStatus.CANCELLED) .set("reservationDate", LocalDate.now().plusDays(i)) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sampleStream() .limit(1) .forEach(reservationRepository::save); @@ -757,6 +832,11 @@ void t11() { .set("shipFishingPostId", (long)i) .set("memberId", givenMemberId) .set("status", ReservationStatus.CONFIRMED) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sample(); reservationRepository.save(givenReservation); @@ -769,6 +849,11 @@ void t11() { .set("shipFishingPostId", (long)i) .set("memberId", givenMemberId) .set("status", ReservationStatus.CANCELLED) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sample(); reservationRepository.save(givenReservation); @@ -816,6 +901,11 @@ void t12() { .set("reservationDate", LocalDate.now().plusDays(i)) .set("status", ReservationStatus.CONFIRMED) .set("createdAt", ZonedDateTime.now().minusDays(2L * i)) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sample(); reservationRepository.save(givenReservation); @@ -831,6 +921,11 @@ void t12() { .set("reservationDate", LocalDate.now().plusDays(i * 6L)) .set("status", ReservationStatus.CONFIRMED) .set("createdAt", ZonedDateTime.now()) + .set("paymentKey", "test") + .set("paymentMethod", "test") + .set("cardNumber", "121354556") + .set("cardApproveNo", "21354") + .set("receiptUrl", "testurl") .sample(); reservationRepository.save(givenReservation); diff --git a/backend/src/test/java/com/backend/domain/reservationdate/repository/ReservationDateRepositoryTest.java b/backend/src/test/java/com/backend/domain/reservationdate/repository/ReservationDateRepositoryTest.java index 985ed8a8f..c78812c5a 100644 --- a/backend/src/test/java/com/backend/domain/reservationdate/repository/ReservationDateRepositoryTest.java +++ b/backend/src/test/java/com/backend/domain/reservationdate/repository/ReservationDateRepositoryTest.java @@ -7,8 +7,10 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.TimeZone; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -49,6 +51,11 @@ public class ReservationDateRepositoryTest extends BaseTest { @Autowired private ReservationDateJpaRepository reservationDateJpaRepository; + @BeforeAll + static void beforeAll() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + @AfterEach public void tearDown() { reservationDateJpaRepository.deleteAllInBatch(); @@ -241,6 +248,9 @@ void t07() { // When reservationDateRepository.deleteOrphanReservationDate(); + em.flush(); + em.clear(); + // Then List findReservationDateList = reservationDateRepository.findAll();