Skip to content

Commit 4310fb3

Browse files
committed
feat: 예약 시스템에 토스 페이먼츠 적용 (MIKKI-216)
1 parent 3741502 commit 4310fb3

11 files changed

Lines changed: 233 additions & 3 deletions

File tree

backend/src/main/java/com/backend/domain/reservation/controller/ReservationController.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import org.springframework.http.ResponseEntity;
66
import org.springframework.security.core.annotation.AuthenticationPrincipal;
77
import org.springframework.web.bind.annotation.GetMapping;
8+
import org.springframework.web.bind.annotation.ModelAttribute;
89
import org.springframework.web.bind.annotation.PatchMapping;
910
import org.springframework.web.bind.annotation.PathVariable;
1011
import org.springframework.web.bind.annotation.PostMapping;
@@ -20,6 +21,7 @@
2021
import com.backend.global.dto.request.GlobalRequest;
2122
import com.backend.global.dto.response.GenericResponse;
2223
import com.backend.global.dto.response.ScrollResponse;
24+
import com.backend.global.payment.dto.request.TossPaymentRequest;
2325

2426
import io.swagger.v3.oas.annotations.Operation;
2527
import io.swagger.v3.oas.annotations.Parameter;
@@ -36,6 +38,29 @@ public class ReservationController {
3638

3739
private final ReservationService reservationService;
3840

41+
@PostMapping("/prepare")
42+
@Operation(summary = "예약 준비 - 주문서 생성", description = "결제를 위한 예약 주문서 생성")
43+
public ResponseEntity<GenericResponse<ReservationResponse.Detail>> prepareReservation(
44+
@RequestBody @Valid final ReservationRequest.Reserve requestDto,
45+
@AuthenticationPrincipal final CustomOAuth2User user) {
46+
47+
ReservationResponse.Detail response = reservationService.prepareReservation(requestDto, user.getId());
48+
49+
return ResponseEntity.created(URI.create("/api/v1/reservations/" + response.reservationId()))
50+
.body(GenericResponse.of(true, response));
51+
}
52+
53+
@GetMapping("/confirm")
54+
@Operation(summary = "결제 승인 및 예약 확정", description = "Toss 결제 성공 후 결제 승인 API 호출")
55+
public ResponseEntity<GenericResponse<Void>> confirmReservationPayment(
56+
@ModelAttribute final TossPaymentRequest request,
57+
@AuthenticationPrincipal final CustomOAuth2User user) {
58+
59+
reservationService.confirmReservationPayment(request, user.getId());
60+
61+
return ResponseEntity.ok(GenericResponse.of(true));
62+
}
63+
3964
@PostMapping
4065
@Operation(summary = "예약 신청 및 생성", description = "유저가 선상 낚시를 예약 할 때 사용하는 API")
4166
public ResponseEntity<GenericResponse<ReservationResponse.Detail>> saveReservation(

backend/src/main/java/com/backend/domain/reservation/dto/response/ReservationResponse.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,21 @@ public record DetailWithName(
129129
) {
130130
}
131131

132+
/**
133+
*
134+
* @param reservationId
135+
* @param shipFishingPostId
136+
* @param reservationNumber
137+
* @param subject
138+
* @param reservationDate
139+
* @param startTime
140+
* @param location
141+
* @param guestCount
142+
* @param totalPrice
143+
* @param reservationStatus
144+
* @param fileIdList
145+
* @param createdAt
146+
*/
132147
@Builder
133148
public record DetailQueryDto(
134149
Long reservationId,
@@ -149,6 +164,21 @@ public record DetailQueryDto(
149164
}
150165
}
151166

167+
/**
168+
*
169+
* @param reservationId
170+
* @param shipFishingPostId
171+
* @param reservationNumber
172+
* @param subject
173+
* @param reservationDate
174+
* @param startTime
175+
* @param location
176+
* @param guestCount
177+
* @param totalPrice
178+
* @param reservationStatus
179+
* @param fileUrlList
180+
* @param createdAt
181+
*/
152182
@Builder
153183
public record DetailReservationList(
154184
Long reservationId,

backend/src/main/java/com/backend/domain/reservation/entity/Reservation.java

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.backend.domain.reservation.entity;
22

33
import java.time.LocalDate;
4+
import java.time.OffsetDateTime;
45

56
import com.backend.global.baseentity.BaseEntity;
7+
import com.backend.global.payment.dto.response.TossPaymentResponse;
68

79
import jakarta.persistence.Column;
810
import jakarta.persistence.Entity;
@@ -58,7 +60,28 @@ public class Reservation extends BaseEntity {
5860
@Builder.Default
5961
@Enumerated(EnumType.STRING)
6062
@Column(nullable = false)
61-
private ReservationStatus status = ReservationStatus.CONFIRMED;
63+
private ReservationStatus status = ReservationStatus.PENDING;
64+
65+
@Column(length = 100)
66+
private String paymentKey;
67+
68+
@Column(length = 20)
69+
private String paymentMethod;
70+
71+
@Column(length = 50)
72+
private String cardNumber;
73+
74+
@Column(length = 20)
75+
private String cardApproveNo;
76+
77+
@Column(length = 1024)
78+
private String receiptUrl;
79+
80+
@Column
81+
private Long totalAmount;
82+
83+
@Column
84+
private OffsetDateTime approvedAt;
6285

6386
public void updatePending(final Boolean isSuccess) {
6487
this.status = isSuccess ? ReservationStatus.CONFIRMED : ReservationStatus.REJECTED;
@@ -67,4 +90,18 @@ public void updatePending(final Boolean isSuccess) {
6790
public void updateCanceled() {
6891
this.status = this.status == ReservationStatus.CONFIRMED ? ReservationStatus.CANCELLED : this.status;
6992
}
93+
94+
public void updateTossPaymentInfo(final TossPaymentResponse response) {
95+
this.paymentKey = response.getPaymentKey();
96+
this.approvedAt = response.getApprovedAt();
97+
this.paymentMethod = response.getMethod();
98+
this.totalAmount = response.getTotalAmount();
99+
if (response.getCard() != null) {
100+
this.cardNumber = response.getCard().getNumber();
101+
this.cardApproveNo = response.getCard().getApproveNo();
102+
}
103+
if (response.getReceipt() != null) {
104+
this.receiptUrl = response.getReceipt().getUrl();
105+
}
106+
}
70107
}

backend/src/main/java/com/backend/domain/reservation/exception/ReservationErrorCode.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ public enum ReservationErrorCode implements ErrorCode {
1515
WRONG_PRICE_VALUE(HttpStatus.BAD_REQUEST, 11002, "예약 금액이 일치하지 않습니다."),
1616
NOT_AVAILABLE_DATE_RESERVATION(HttpStatus.CONFLICT, 11003, "예약 가능한 일자가 아닙니다."),
1717
NOT_AVAILABLE_END_RESERVATION(HttpStatus.CONFLICT, 11004, "예약 정원 초과 입니다."),
18-
NOT_AUTHORITY_RESERVATION(HttpStatus.FORBIDDEN, 11005, "해당 예약에 대한 권한이 없습니다.");
18+
NOT_AUTHORITY_RESERVATION(HttpStatus.FORBIDDEN, 11005, "해당 예약에 대한 권한이 없습니다."),
19+
ALREADY_CONFIRMED_RESERVATION(HttpStatus.BAD_REQUEST, 11006, "해당 예약은 이미 완료되었습니다.");
1920

2021
private final HttpStatus httpStatus;
2122
private final Integer code;

backend/src/main/java/com/backend/domain/reservation/repository/ReservationJpaRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.backend.domain.reservation.repository;
22

3+
import java.util.Optional;
4+
35
import org.springframework.data.jpa.repository.JpaRepository;
46

57
import com.backend.domain.reservation.entity.Reservation;
@@ -9,4 +11,5 @@ public interface ReservationJpaRepository extends JpaRepository<Reservation, Lon
911

1012
Long countByMemberIdAndStatus(final Long memberId, final ReservationStatus status);
1113

14+
Optional<Reservation> findByReservationNumber(final String reservationNumber);
1215
}

backend/src/main/java/com/backend/domain/reservation/repository/ReservationRepository.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ public interface ReservationRepository {
2828
*/
2929
Optional<Reservation> findById(final Long reservationId);
3030

31+
/**
32+
* 예약 번호로 예약 정보 조회 메서드
33+
*
34+
* @param reservationNumber 예약번호
35+
* @return 예약 데이터
36+
* @implSpec 예약 번호와 일치하는 예약 정보를 조회합니다.
37+
*/
38+
Optional<Reservation> findByReservationNumber(final String reservationNumber);
39+
3140
/**
3241
* 유저의 예약 내역 횟수를 조회합니다.
3342
*

backend/src/main/java/com/backend/domain/reservation/repository/ReservationRepositoryImpl.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ public Optional<Reservation> findById(final Long reservationId) {
3232
return reservationJpaRepository.findById(reservationId);
3333
}
3434

35+
@Override
36+
public Optional<Reservation> findByReservationNumber(final String reservationNumber) {
37+
38+
return reservationJpaRepository.findByReservationNumber(reservationNumber);
39+
}
40+
3541
@Override
3642
public Long getReservationCount(final Long memberId) {
3743

backend/src/main/java/com/backend/domain/reservation/service/ReservationService.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,26 @@
44
import com.backend.domain.reservation.dto.response.ReservationResponse;
55
import com.backend.global.dto.request.GlobalRequest;
66
import com.backend.global.dto.response.ScrollResponse;
7+
import com.backend.global.payment.dto.request.TossPaymentRequest;
78

89
public interface ReservationService {
910

11+
/**
12+
* 예약 전에 주문서를 생성하는 메서드
13+
*
14+
* @param requestDto 예약 정보
15+
* @param memberId 유저 ID
16+
* @return 주문서 정보
17+
*/
18+
ReservationResponse.Detail prepareReservation(final ReservationRequest.Reserve requestDto, final Long memberId);
19+
20+
/**
21+
* 결제 요청 후 재고 차감 여부로 결제 메서드
22+
*
23+
* @param requestDto 토스 결제 정보
24+
*/
25+
void confirmReservationPayment(final TossPaymentRequest requestDto, final Long memberId);
26+
1027
/**
1128
* 예약을 생성하는 메서드
1229
*

backend/src/main/java/com/backend/domain/reservation/service/ReservationServiceImpl.java

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.backend.domain.reservation.dto.request.ReservationRequest;
1111
import com.backend.domain.reservation.dto.response.ReservationResponse;
1212
import com.backend.domain.reservation.entity.Reservation;
13+
import com.backend.domain.reservation.entity.ReservationStatus;
1314
import com.backend.domain.reservation.exception.ReservationErrorCode;
1415
import com.backend.domain.reservation.exception.ReservationException;
1516
import com.backend.domain.reservation.repository.ReservationRepository;
@@ -21,6 +22,9 @@
2122
import com.backend.domain.shipfishingpost.repository.ShipFishingPostRepository;
2223
import com.backend.global.dto.request.GlobalRequest;
2324
import com.backend.global.dto.response.ScrollResponse;
25+
import com.backend.global.payment.TossPaymentHttpClient;
26+
import com.backend.global.payment.dto.request.TossPaymentRequest;
27+
import com.backend.global.payment.dto.response.TossPaymentResponse;
2428

2529
import lombok.RequiredArgsConstructor;
2630
import lombok.extern.slf4j.Slf4j;
@@ -35,6 +39,55 @@ public class ReservationServiceImpl implements ReservationService {
3539
private final ReservationRepository reservationRepository;
3640
private final ReservationDateRepository reservationDateRepository;
3741
private final ShipFishingPostRepository shipFishingPostRepository;
42+
private final TossPaymentHttpClient tossPaymentHttpClient;
43+
44+
@Override
45+
public ReservationResponse.Detail prepareReservation(
46+
final ReservationRequest.Reserve requestDto,
47+
final Long memberId) {
48+
49+
verifyTodayAfterDate(requestDto.reservationDate());
50+
51+
ShipFishingPost shipFishingPost = getShipFishingPostEntity(requestDto.shipFishingPostId());
52+
53+
verifyPriceValue(requestDto.price(), requestDto.totalPrice(), shipFishingPost.getPrice(),
54+
shipFishingPost.getPrice() * requestDto.guestCount());
55+
56+
ReservationDate reservationDate = getReservationDate(shipFishingPost.getShipFishingPostId(),
57+
requestDto.reservationDate());
58+
59+
verifyReservationDate(reservationDate, requestDto.guestCount());
60+
61+
Reservation reservation = reservationRepository.save(
62+
ReservationConverter.fromReservationRequest(requestDto, memberId));
63+
64+
return ReservationConverter.fromReservationResponseDetail(reservation);
65+
}
66+
67+
@Override
68+
@Transactional
69+
public void confirmReservationPayment(final TossPaymentRequest requestDto, final Long memberId) {
70+
71+
Reservation reservation = verifyPayment(requestDto, memberId);
72+
73+
try {
74+
TossPaymentResponse response = tossPaymentHttpClient.sendPaymentConfirmRequest(requestDto);
75+
76+
updateReservationDateWithRemainCount(reservation.getShipFishingPostId(), reservation.getReservationDate(),
77+
reservation.getGuestCount(), false);
78+
79+
reservation.updatePending(true);
80+
reservation.updateTossPaymentInfo(response);
81+
82+
activityHistoryService.createActivityHistory(reservation);
83+
} catch (ReservationException e) {
84+
tossPaymentHttpClient.cancelPayment(requestDto.paymentKey(), "예약 불가: 재고 부족", requestDto.amount());
85+
86+
reservation.updatePending(false);
87+
88+
throw e;
89+
}
90+
}
3891

3992
@Override
4093
@Transactional
@@ -160,6 +213,19 @@ private ShipFishingPost getShipFishingPostEntity(final Long shipFishingPostId) {
160213
.orElseThrow(() -> new ShipFishingPostException(ShipFishingPostErrorCode.POSTS_NOT_FOUND));
161214
}
162215

216+
/**
217+
* 예약 날짜 데이터 조회 메서드
218+
*
219+
* @param shipFishingPostId 게시글 ID
220+
* @param reservationDate 예약 일자
221+
* @return 예약 일자 데이터
222+
*/
223+
private ReservationDate getReservationDate(final Long shipFishingPostId, final LocalDate reservationDate) {
224+
225+
return reservationDateRepository.findByShipFishingPostIdAndReservationDate(shipFishingPostId, reservationDate)
226+
.orElseThrow(() -> new ReservationException(ReservationErrorCode.NOT_AVAILABLE_DATE_RESERVATION));
227+
}
228+
163229
/**
164230
* 오늘포함 이전 예약 신청, 수정 불가 검증 메서드
165231
*
@@ -229,6 +295,33 @@ private void verifyAuthorization(
229295
}
230296
}
231297

298+
/**
299+
* 토스 페이먼츠 주문서 검증 메서드
300+
*
301+
* @param requestDto 토스페이먼츠 데이터
302+
* @param memberId 유저 ID
303+
* @return {@link Reservation} 예약정보
304+
*/
305+
private Reservation verifyPayment(final TossPaymentRequest requestDto, final Long memberId) {
306+
307+
Reservation reservation = reservationRepository.findByReservationNumber(requestDto.orderId())
308+
.orElseThrow(() -> new ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND));
309+
310+
if (reservation.getStatus() != ReservationStatus.PENDING) {
311+
throw new ReservationException(ReservationErrorCode.ALREADY_CONFIRMED_RESERVATION);
312+
}
313+
314+
if (!memberId.equals(reservation.getMemberId())) {
315+
throw new ReservationException(ReservationErrorCode.NOT_AUTHORITY_RESERVATION);
316+
}
317+
318+
if (!reservation.getTotalPrice().equals(requestDto.amount())) {
319+
throw new ReservationException(ReservationErrorCode.WRONG_PRICE_VALUE);
320+
}
321+
322+
return reservation;
323+
}
324+
232325
/**
233326
* 예약 일자 조회, 예약 가능하면 예약 일자 정보 남은 인원 업데이트 메서드입니다.
234327
*

backend/src/main/java/com/backend/domain/reservation/util/ReservationUtil.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public class ReservationUtil {
1212
*/
1313
public static String generateReservationNumber() {
1414
LocalDate now = LocalDate.now();
15-
String UUIDNumber = UUID.randomUUID().toString().substring(0, 6);
15+
String UUIDNumber = UUID.randomUUID().toString().substring(0, 8);
1616

1717
return String.format("%s-%s", now, UUIDNumber);
1818
}

0 commit comments

Comments
 (0)