Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
347 changes: 321 additions & 26 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.cgv.spring_boot.domain.payment.dto;

public record PaymentCancelResult(
Long paymentPk,
Long reservationId,
String paymentId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.cgv.spring_boot.domain.payment.dto;

public record PaymentReadyResult(
Long paymentPk,
Long reservationId,
String paymentId,
String orderName,
int totalAmount,
String currency,
String customData
) {
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.cgv.spring_boot.domain.payment.service;

import com.cgv.spring_boot.domain.payment.dto.request.PaymentCreateRequest;
import com.cgv.spring_boot.domain.payment.dto.PaymentCancelResult;
import com.cgv.spring_boot.domain.payment.dto.PaymentReadyResult;
import com.cgv.spring_boot.domain.payment.dto.response.PaymentResponse;
import com.cgv.spring_boot.domain.payment.entity.Payment;
import com.cgv.spring_boot.domain.payment.entity.PaymentStatus;
Expand All @@ -25,7 +27,7 @@ public class PaymentService {
private final PaymentIdGenerator paymentIdGenerator;

@Transactional
public PaymentResponse payReservation(Reservation reservation, int totalAmount, String orderName, String customData) {
public PaymentReadyResult createReadyPayment(Reservation reservation, int totalAmount, String orderName, String customData) {
if (paymentRepository.existsByReservationId(reservation.getId())) {
log.warn("payment rejected. reservationId={}, reason=payment_already_exists", reservation.getId());
throw new BusinessException(PaymentErrorCode.PAYMENT_ALREADY_EXISTS);
Expand All @@ -38,37 +40,67 @@ public PaymentResponse payReservation(Reservation reservation, int totalAmount,
Payment.createReady(reservation, paymentId, orderName, totalAmount, DEFAULT_CURRENCY, customData)
);

try {
PaymentResponse response = portOnePaymentClient.instantPay(paymentId, new PaymentCreateRequest(
orderName,
totalAmount,
DEFAULT_CURRENCY,
customData
));
payment.markPaid(response.pgProvider(), response.paidAt());
log.info("AUDIT payment succeeded. reservationId={}, paymentId={}, provider={}",
reservation.getId(), response.paymentId(), response.pgProvider());
return response;
} catch (BusinessException e) {
payment.markFailed();
log.warn("AUDIT payment failed. reservationId={}, paymentId={}, reason={}",
reservation.getId(), paymentId, e.getErrorCode().getMessage());
throw e;
}
return new PaymentReadyResult(
payment.getId(),
payment.getReservation().getId(),
payment.getPaymentId(),
payment.getOrderName(),
payment.getTotalAmount(),
payment.getCurrency(),
payment.getCustomData()
);
}

public PaymentResponse requestPayment(PaymentReadyResult payment) {
return portOnePaymentClient.instantPay(payment.paymentId(), new PaymentCreateRequest(
payment.orderName(),
payment.totalAmount(),
payment.currency(),
payment.customData()
));
}

@Transactional
public void markPaymentPaid(Long paymentPk, PaymentResponse response) {
Payment payment = paymentRepository.findById(paymentPk)
.orElseThrow(() -> new BusinessException(PaymentErrorCode.PAYMENT_NOT_FOUND));
payment.markPaid(response.pgProvider(), response.paidAt());
log.info("AUDIT payment succeeded. reservationId={}, paymentId={}, provider={}",
payment.getReservation().getId(), response.paymentId(), response.pgProvider());
}

@Transactional
public void markPaymentFailed(Long paymentPk, BusinessException e) {
Payment payment = paymentRepository.findById(paymentPk)
.orElseThrow(() -> new BusinessException(PaymentErrorCode.PAYMENT_NOT_FOUND));
payment.markFailed();
log.warn("AUDIT payment failed. reservationId={}, paymentId={}, reason={}",
payment.getReservation().getId(), payment.getPaymentId(), e.getErrorCode().getMessage());
}

@Transactional
public void cancelReservationPayment(Reservation reservation) {
public PaymentCancelResult getPaidPaymentForCancel(Reservation reservation) {
Payment payment = paymentRepository.findByReservationId(reservation.getId())
.orElse(null);

if (payment == null || payment.getStatus() != PaymentStatus.PAID) {
return;
return null;
}

portOnePaymentClient.cancel(payment.getPaymentId());
return new PaymentCancelResult(payment.getId(), payment.getReservation().getId(), payment.getPaymentId());
}

public void requestPaymentCancel(PaymentCancelResult payment) {
portOnePaymentClient.cancel(payment.paymentId());
}

@Transactional
public void markPaymentCancelled(Long paymentPk) {
Payment payment = paymentRepository.findById(paymentPk)
.orElseThrow(() -> new BusinessException(PaymentErrorCode.PAYMENT_NOT_FOUND));
payment.cancel();
log.info("AUDIT payment cancelled. reservationId={}, paymentId={}",
reservation.getId(), payment.getPaymentId());
payment.getReservation().getId(), payment.getPaymentId());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
@Entity
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Table(indexes = {
@Index(name = "idx_reservation_status_expires_at", columnList = "status, expires_at")
})
public class Reservation extends BaseEntity {

@Id
Expand All @@ -27,7 +30,7 @@ public class Reservation extends BaseEntity {
@Column(nullable = false)
private ReservationStatus status; // BOOKED, CANCELED

@Column(nullable = false)
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;

@ManyToOne(fetch = FetchType.LAZY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Table(
indexes = {
@Index(name = "idx_reserved_seat_reservation_id", columnList = "res_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_reserved_seat_per_schedule", columnNames = {"seat_row", "seat_col", "schedule_id"})
@UniqueConstraint(name = "uk_reserved_seat_per_schedule", columnNames = {"schedule_id", "seat_row", "seat_col"})
}
)
public class ReservedSeat extends BaseEntity {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.cgv.spring_boot.domain.reservation.service;

import com.cgv.spring_boot.domain.payment.dto.response.PaymentResponse;
import com.cgv.spring_boot.domain.payment.dto.PaymentCancelResult;
import com.cgv.spring_boot.domain.payment.dto.PaymentReadyResult;
import com.cgv.spring_boot.domain.payment.service.PaymentService;
import com.cgv.spring_boot.domain.reservation.dto.ReservationRequest;
import com.cgv.spring_boot.domain.reservation.exception.ReservationErrorCode;
Expand All @@ -22,7 +24,9 @@
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;

import java.time.LocalDateTime;
import java.util.List;
Expand All @@ -39,6 +43,7 @@ public class ReservationService {
private final UserRepository userRepository;
private final ReservedSeatRepository reservedSeatRepository;
private final PaymentService paymentService;
private final TransactionTemplate transactionTemplate;

/**
* 예매 좌석 선점
Expand Down Expand Up @@ -117,8 +122,21 @@ private void validateAlreadyReservedSeats(Schedule schedule, List<SeatPosition>
/**
* 예매 결제 및 확정
*/
@Transactional
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public PaymentResponse pay(Long userId, Long reservationId) {
PaymentReadyResult payment = transactionTemplate.execute(status -> preparePayment(userId, reservationId));

try {
PaymentResponse response = paymentService.requestPayment(payment);
transactionTemplate.executeWithoutResult(status -> completePayment(userId, payment, response));
return response;
} catch (BusinessException e) {
transactionTemplate.executeWithoutResult(status -> paymentService.markPaymentFailed(payment.paymentPk(), e));
throw e;
}
}

private PaymentReadyResult preparePayment(Long userId, Long reservationId) {
Reservation reservation = getOwnedReservation(userId, reservationId);
validateReservationPayable(reservation);

Expand All @@ -128,11 +146,16 @@ public PaymentResponse pay(Long userId, Long reservationId) {
String orderName = schedule.getMovie().getTitle() + " 예매";
String customData = "{\"reservationId\":" + reservationId + ",\"seatCount\":" + seatCount + "}";

PaymentResponse response = paymentService.payReservation(reservation, totalAmount, orderName, customData);
return paymentService.createReadyPayment(reservation, totalAmount, orderName, customData);
}

private void completePayment(Long userId, PaymentReadyResult payment, PaymentResponse response) {
Reservation reservation = reservationRepository.findById(payment.reservationId())
.orElseThrow(() -> new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND));
paymentService.markPaymentPaid(payment.paymentPk(), response);
reservation.confirm();
log.info("AUDIT reservation paid. userId={}, reservationId={}, paymentId={}, totalAmount={}",
userId, reservationId, response.paymentId(), totalAmount);
return response;
userId, payment.reservationId(), response.paymentId(), payment.totalAmount());
}

/** 결제 가능 예약 검증 */
Expand All @@ -154,14 +177,37 @@ private void validateReservationPayable(Reservation reservation) {
/**
* 예매 취소
*/
@Transactional
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void cancel(Long userId, Long reservationId) {
PaymentCancelResult payment = transactionTemplate.execute(status -> prepareCancel(userId, reservationId));

if (payment != null) {
paymentService.requestPaymentCancel(payment);
transactionTemplate.executeWithoutResult(status -> completeCancel(userId, reservationId, payment));
}
}

private PaymentCancelResult prepareCancel(Long userId, Long reservationId) {
Reservation reservation = getOwnedReservation(userId, reservationId);
reservation.cancel();
paymentService.cancelReservationPayment(reservation);
PaymentCancelResult payment = paymentService.getPaidPaymentForCancel(reservation);

if (payment == null) {
cancelReservation(userId, reservation);
}

return payment;
}

private void completeCancel(Long userId, Long reservationId, PaymentCancelResult payment) {
Reservation reservation = getOwnedReservation(userId, reservationId);
paymentService.markPaymentCancelled(payment.paymentPk());
cancelReservation(userId, reservation);
}

private void cancelReservation(Long userId, Reservation reservation) {
reservation.cancel();
reservedSeatRepository.deleteByReservation(reservation);
log.info("AUDIT reservation cancelled. userId={}, reservationId={}", userId, reservationId);
log.info("AUDIT reservation cancelled. userId={}, reservationId={}", userId, reservation.getId());
}

/** 본인 예약 조회 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(indexes = {
@Index(name = "idx_store_inventory_theater_item", columnList = "theater_id, item_id")
})
public class StoreInventory extends BaseEntity {

@Id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import com.cgv.spring_boot.domain.reservation.dto.ReservationRequest;
import com.cgv.spring_boot.domain.reservation.entity.Reservation;
import com.cgv.spring_boot.domain.reservation.entity.ReservedSeat;
import com.cgv.spring_boot.domain.reservation.repository.ReservationRepository;
import com.cgv.spring_boot.domain.reservation.repository.ReservedSeatRepository;
import com.cgv.spring_boot.domain.schedule.entity.Schedule;
import com.cgv.spring_boot.domain.schedule.repository.ScheduleRepository;
import com.cgv.spring_boot.domain.theater.entity.Hall;
import com.cgv.spring_boot.domain.theater.entity.HallType;
import com.cgv.spring_boot.domain.user.entity.User;
import com.cgv.spring_boot.domain.user.repository.UserRepository;
import com.cgv.spring_boot.domain.reservation.exception.ReservationErrorCode;
Expand Down Expand Up @@ -48,20 +49,29 @@ void reserve_success() {

User user = mock(User.class);
Schedule schedule = mock(Schedule.class);
Hall hall = mock(Hall.class);
HallType hallType = HallType.builder()
.typeName("일반관")
.rowCount(10)
.colCount(10)
.build();
Reservation reservation = Reservation.builder().build();

given(userRepository.findById(userId)).willReturn(Optional.of(user));
given(scheduleRepository.findById(request.scheduleId())).willReturn(Optional.of(schedule));
given(reservedSeatRepository.findAllByScheduleAndRowsAndCols(anyLong(), anyList(), anyList()))
.willReturn(List.of());
given(schedule.getId()).willReturn(request.scheduleId());
given(schedule.getHall()).willReturn(hall);
given(hall.getHallType()).willReturn(hallType);
given(reservedSeatRepository.existsByScheduleIdAndSeatRowAndSeatCol(anyLong(), anyString(), anyInt()))
.willReturn(false);
given(reservationRepository.save(any(Reservation.class))).willReturn(reservation);

// when
Long resultId = reservationService.reserve(userId, request);

// then
verify(reservationRepository, times(1)).save(any(Reservation.class));
verify(reservedSeatRepository, times(1)).saveAll(anyList());
verify(reservedSeatRepository, times(1)).saveAllAndFlush(anyList());
}

@Test
Expand All @@ -70,12 +80,21 @@ void reserve_fail_already_reserved() {
// given
Long userId = 1L;
ReservationRequest request = new ReservationRequest(10L, List.of(new ReservationRequest.SeatRequest("A", 1)));
Schedule schedule = mock(Schedule.class);
Hall hall = mock(Hall.class);
HallType hallType = HallType.builder()
.typeName("일반관")
.rowCount(10)
.colCount(10)
.build();

given(userRepository.findById(userId)).willReturn(Optional.of(mock(User.class)));
given(scheduleRepository.findById(anyLong())).willReturn(Optional.of(mock(Schedule.class)));

given(reservedSeatRepository.findAllByScheduleAndRowsAndCols(anyLong(), anyList(), anyList()))
.willReturn(List.of(mock(ReservedSeat.class)));
given(scheduleRepository.findById(anyLong())).willReturn(Optional.of(schedule));
given(schedule.getId()).willReturn(request.scheduleId());
given(schedule.getHall()).willReturn(hall);
given(hall.getHallType()).willReturn(hallType);
given(reservedSeatRepository.existsByScheduleIdAndSeatRowAndSeatCol(anyLong(), anyString(), anyInt()))
.willReturn(true);

// when(then)
assertThatThrownBy(() -> reservationService.reserve(userId, request))
Expand Down