From b9a08b892a359c80721834470b4c1147aa61329a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 00:00:43 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EC=B0=B8=EC=97=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0=20=ED=99=95=EC=9D=B8=20=EC=9A=94=EC=B2=AD,?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8,=20=EA=B1=B0=EC=A0=88=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/CommandPaymentRequest.java | 32 +++++ .../application/impl/MemberExpenseReader.java | 8 +- .../event/application/impl/MemberReader.java | 6 + .../event/application/impl/MemberUpdater.java | 9 +- .../impl/PaymentRequestCreator.java | 39 ++++++ .../impl/PaymentRequestUpdater.java | 34 +++++ .../impl/PaymentRequestValidator.java | 74 ++++++++++ .../domain/paymentRequest/PaymentRequest.java | 84 ++++++++++++ .../paymentRequest/PaymentRequestStatus.java | 5 + ...plicatePendingPaymentRequestException.java | 14 ++ ...agerPaymentRequestNotAllowedException.java | 11 ++ ...aymentRequestAlreadyApprovedException.java | 14 ++ .../PaymentRequestNotFoundException.java | 11 ++ .../PaymentRequestNotPendingException.java | 15 +++ .../PaymentRequestUnauthorizedException.java | 14 ++ .../MemberExpenseRepository.java | 3 + .../infrastructure/MemberRepository.java | 9 ++ .../PaymentRequestRepository.java | 34 +++++ .../PaymentRequestController.java | 52 +++++++ .../response/PaymentRequestResponse.java | 28 ++++ .../user/application/impl/UserReader.java | 7 +- .../PaymentRequestControllerTest.java | 127 ++++++++++++++++++ .../service/CommandPaymentRequestTest.java | 78 +++++++++++ .../PaymentRequestCreatorTest.java | 80 +++++++++++ .../PaymentRequestUpdaterTest.java | 68 ++++++++++ .../PaymentRequestValidatorTest.java | 99 ++++++++++++++ .../dnd/moddo/global/util/ControllerTest.java | 6 + 27 files changed, 955 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/dnd/moddo/event/application/command/CommandPaymentRequest.java create mode 100644 src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestCreator.java create mode 100644 src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestUpdater.java create mode 100644 src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestValidator.java create mode 100644 src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequest.java create mode 100644 src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequestStatus.java create mode 100644 src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/DuplicatePendingPaymentRequestException.java create mode 100644 src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/ManagerPaymentRequestNotAllowedException.java create mode 100644 src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestAlreadyApprovedException.java create mode 100644 src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestNotFoundException.java create mode 100644 src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestNotPendingException.java create mode 100644 src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestUnauthorizedException.java create mode 100644 src/main/java/com/dnd/moddo/event/infrastructure/PaymentRequestRepository.java create mode 100644 src/main/java/com/dnd/moddo/event/presentation/PaymentRequestController.java create mode 100644 src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestResponse.java create mode 100644 src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestCreatorTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestUpdaterTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestValidatorTest.java diff --git a/src/main/java/com/dnd/moddo/event/application/command/CommandPaymentRequest.java b/src/main/java/com/dnd/moddo/event/application/command/CommandPaymentRequest.java new file mode 100644 index 00000000..cf16d810 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/application/command/CommandPaymentRequest.java @@ -0,0 +1,32 @@ +package com.dnd.moddo.event.application.command; + +import org.springframework.stereotype.Service; + +import com.dnd.moddo.event.application.impl.PaymentRequestCreator; +import com.dnd.moddo.event.application.impl.PaymentRequestUpdater; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.presentation.response.PaymentRequestResponse; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class CommandPaymentRequest { + private final PaymentRequestCreator paymentRequestCreator; + private final PaymentRequestUpdater paymentRequestUpdater; + + public PaymentRequestResponse createPaymentRequest(Long settlementId, Long userId) { + PaymentRequest paymentRequest = paymentRequestCreator.createPaymentRequest(settlementId, userId); + return PaymentRequestResponse.of(paymentRequest); + } + + public PaymentRequestResponse approvePaymentRequest(Long paymentRequestId, Long userId) { + PaymentRequest paymentRequest = paymentRequestUpdater.approvePaymentRequest(paymentRequestId, userId); + return PaymentRequestResponse.of(paymentRequest); + } + + public PaymentRequestResponse rejectPaymentRequest(Long paymentRequestId, Long userId) { + PaymentRequest paymentRequest = paymentRequestUpdater.rejectPaymentRequest(paymentRequestId, userId); + return PaymentRequestResponse.of(paymentRequest); + } +} diff --git a/src/main/java/com/dnd/moddo/event/application/impl/MemberExpenseReader.java b/src/main/java/com/dnd/moddo/event/application/impl/MemberExpenseReader.java index dbf2220b..bd47b053 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/MemberExpenseReader.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/MemberExpenseReader.java @@ -20,11 +20,15 @@ public List findAllByExpenseId(Long expenseId) { return memberExpenseRepository.findByExpenseId(expenseId); } - public List findAllByAppointMemberIds(List appointMemberIds) { - return memberExpenseRepository.findAllByAppointmentMemberIds(appointMemberIds); + public List findAllByAppointMemberIds(List memberIds) { + return memberExpenseRepository.findAllByAppointmentMemberIds(memberIds); } public List findAllByExpenseIds(List expenseIds) { return memberExpenseRepository.findAllByExpenseIds(expenseIds); } + + public List getMemberExpenseByMemberId(Long memberId) { + return memberExpenseRepository.findByMemberId(memberId); + } } diff --git a/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java b/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java index a1abe694..5e3a7d34 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java @@ -6,6 +6,7 @@ import org.springframework.transaction.annotation.Transactional; import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.member.exception.MemberNotFoundException; import com.dnd.moddo.event.domain.member.type.MemberSortType; import com.dnd.moddo.event.infrastructure.MemberQueryRepository; import com.dnd.moddo.event.infrastructure.MemberRepository; @@ -35,4 +36,9 @@ public List findIdsBySettlementId(Long settlementId) { return memberRepository.findMemberIdsBySettlementId(settlementId); } + public Member findBySettlementIdAndUserId(Long settlementId, Long userId) { + return memberRepository.findBySettlementIdAndUserId(settlementId, userId) + .orElseThrow(() -> new MemberNotFoundException(userId)); + } + } diff --git a/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java b/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java index 40e21910..c730093b 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java @@ -61,10 +61,15 @@ public Member addToSettlement(Long settlementId, MemberSaveRequest request) { @Transactional(isolation = Isolation.READ_COMMITTED) public Member updatePaymentStatus(Long appointmentMemberId, PaymentStatusUpdateRequest request) { + return updatePaymentStatus(appointmentMemberId, request.isPaid()); + } + + @Transactional(isolation = Isolation.READ_COMMITTED) + public Member updatePaymentStatus(Long appointmentMemberId, boolean isPaid) { try { Member member = memberRepository.getById(appointmentMemberId); - if (member.isPaid() != request.isPaid()) { - member.updatePaymentStatus(request.isPaid()); + if (member.isPaid() != isPaid) { + member.updatePaymentStatus(isPaid); memberRepository.save(member); } return member; diff --git a/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestCreator.java b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestCreator.java new file mode 100644 index 00000000..dfca94d5 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestCreator.java @@ -0,0 +1,39 @@ +package com.dnd.moddo.event.application.impl; + +import org.springframework.stereotype.Service; + +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.domain.settlement.Settlement; +import com.dnd.moddo.event.infrastructure.PaymentRequestRepository; +import com.dnd.moddo.user.application.impl.UserReader; +import com.dnd.moddo.user.domain.User; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PaymentRequestCreator { + private final PaymentRequestRepository paymentRequestRepository; + private final MemberReader memberReader; + private final SettlementReader settlementReader; + private final UserReader userReader; + private final PaymentRequestValidator paymentRequestValidator; + + public PaymentRequest createPaymentRequest(Long settlementId, Long userId) { + Member requestMember = memberReader.findBySettlementIdAndUserId(settlementId, userId); + Settlement settlement = settlementReader.read(settlementId); + + paymentRequestValidator.validateCreateRequest(settlementId, requestMember); + + User targetUser = userReader.read(settlement.getWriter()); + + PaymentRequest paymentRequest = PaymentRequest.builder() + .settlement(settlement) + .requestMember(requestMember) + .targetUser(targetUser) + .build(); + + return paymentRequestRepository.save(paymentRequest); + } +} diff --git a/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestUpdater.java b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestUpdater.java new file mode 100644 index 00000000..94802c4d --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestUpdater.java @@ -0,0 +1,34 @@ +package com.dnd.moddo.event.application.impl; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.infrastructure.PaymentRequestRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PaymentRequestUpdater { + private final PaymentRequestRepository paymentRequestRepository; + private final PaymentRequestValidator paymentRequestValidator; + private final MemberUpdater memberUpdater; + + @Transactional + public PaymentRequest approvePaymentRequest(Long paymentRequestId, Long userId) { + PaymentRequest paymentRequest = paymentRequestRepository.getById(paymentRequestId); + paymentRequestValidator.validateProcessRequest(paymentRequest, userId); + memberUpdater.updatePaymentStatus(paymentRequest.getRequestMemberId(), true); + paymentRequest.approve(); + return paymentRequest; + } + + @Transactional + public PaymentRequest rejectPaymentRequest(Long paymentRequestId, Long userId) { + PaymentRequest paymentRequest = paymentRequestRepository.getById(paymentRequestId); + paymentRequestValidator.validateProcessRequest(paymentRequest, userId); + paymentRequest.reject(); + return paymentRequest; + } +} diff --git a/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestValidator.java b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestValidator.java new file mode 100644 index 00000000..5bb2f704 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestValidator.java @@ -0,0 +1,74 @@ +package com.dnd.moddo.event.application.impl; + +import org.springframework.stereotype.Component; + +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus; +import com.dnd.moddo.event.domain.paymentRequest.exception.DuplicatePendingPaymentRequestException; +import com.dnd.moddo.event.domain.paymentRequest.exception.ManagerPaymentRequestNotAllowedException; +import com.dnd.moddo.event.domain.paymentRequest.exception.PaymentRequestAlreadyApprovedException; +import com.dnd.moddo.event.domain.paymentRequest.exception.PaymentRequestNotPendingException; +import com.dnd.moddo.event.domain.paymentRequest.exception.PaymentRequestUnauthorizedException; +import com.dnd.moddo.event.infrastructure.PaymentRequestRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PaymentRequestValidator { + private final PaymentRequestRepository paymentRequestRepository; + + public void validateCreateRequest(Long settlementId, Member requestMember) { + validateIsManager(requestMember); + validateDuplicateRequest(settlementId, requestMember.getId()); + validateAlreadyApprovedRequest(settlementId, requestMember.getId()); + } + + public void validateProcessRequest(PaymentRequest paymentRequest, Long userId) { + validatePendingStatus(paymentRequest); + validateTargetUser(paymentRequest, userId); + } + + private void validateDuplicateRequest(Long settlementId, Long requestMemberId) { + boolean exists = paymentRequestRepository.existsBySettlementIdAndRequestMemberIdAndStatus( + settlementId, + requestMemberId, + PaymentRequestStatus.PENDING + ); + + if (exists) { + throw new DuplicatePendingPaymentRequestException(settlementId, requestMemberId); + } + } + + private void validateAlreadyApprovedRequest(Long settlementId, Long requestMemberId) { + boolean exists = paymentRequestRepository.existsBySettlementIdAndRequestMemberIdAndStatus( + settlementId, + requestMemberId, + PaymentRequestStatus.APPROVED + ); + + if (exists) { + throw new PaymentRequestAlreadyApprovedException(settlementId, requestMemberId); + } + } + + private void validateIsManager(Member requestMember) { + if (requestMember.isManager()) { + throw new ManagerPaymentRequestNotAllowedException(requestMember.getId()); + } + } + + private void validatePendingStatus(PaymentRequest paymentRequest) { + if (paymentRequest.getStatus() != PaymentRequestStatus.PENDING) { + throw new PaymentRequestNotPendingException(paymentRequest.getId(), paymentRequest.getStatus()); + } + } + + private void validateTargetUser(PaymentRequest paymentRequest, Long userId) { + if (!paymentRequest.getTargetUser().getId().equals(userId)) { + throw new PaymentRequestUnauthorizedException(paymentRequest.getId(), userId); + } + } +} diff --git a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequest.java b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequest.java new file mode 100644 index 00000000..41237b9e --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequest.java @@ -0,0 +1,84 @@ +package com.dnd.moddo.event.domain.paymentRequest; + +import java.time.LocalDateTime; + +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.settlement.Settlement; +import com.dnd.moddo.user.domain.User; + +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.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "payment_request") +@Entity +public class PaymentRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "settlement_id") + private Settlement settlement; + + @ManyToOne + @JoinColumn(name = "request_member_id") + private Member requestMember; + + @ManyToOne + @JoinColumn(name = "target_user_id") + private User targetUser; + + private LocalDateTime requestedAt; + + private LocalDateTime processedAt; + + @Enumerated(EnumType.STRING) + private PaymentRequestStatus status; + + @Builder + public PaymentRequest(Settlement settlement, Member requestMember, User targetUser) { + this.settlement = settlement; + this.requestMember = requestMember; + this.targetUser = targetUser; + this.requestedAt = LocalDateTime.now(); + this.status = PaymentRequestStatus.PENDING; + } + + public void approve() { + this.status = PaymentRequestStatus.APPROVED; + this.processedAt = LocalDateTime.now(); + } + + public void reject() { + this.status = PaymentRequestStatus.REJECTED; + this.processedAt = LocalDateTime.now(); + } + + public Long getRequestMemberId() { + return requestMember.getId(); + } + + public Long getSettlementId() { + return settlement.getId(); + } + + public Long getTargetUserId() { + return targetUser.getId(); + } + +} diff --git a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequestStatus.java b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequestStatus.java new file mode 100644 index 00000000..647fe9e2 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequestStatus.java @@ -0,0 +1,5 @@ +package com.dnd.moddo.event.domain.paymentRequest; + +public enum PaymentRequestStatus { + PENDING, APPROVED, REJECTED +} diff --git a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/DuplicatePendingPaymentRequestException.java b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/DuplicatePendingPaymentRequestException.java new file mode 100644 index 00000000..98072509 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/DuplicatePendingPaymentRequestException.java @@ -0,0 +1,14 @@ +package com.dnd.moddo.event.domain.paymentRequest.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class DuplicatePendingPaymentRequestException extends ModdoException { + public DuplicatePendingPaymentRequestException(Long settlementId, Long requestMemberId) { + super( + HttpStatus.BAD_REQUEST, + "이미 처리 대기 중인 입금 확인 요청이 있습니다. (Settlement ID: " + settlementId + ", Member ID: " + requestMemberId + ")" + ); + } +} diff --git a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/ManagerPaymentRequestNotAllowedException.java b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/ManagerPaymentRequestNotAllowedException.java new file mode 100644 index 00000000..833d226a --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/ManagerPaymentRequestNotAllowedException.java @@ -0,0 +1,11 @@ +package com.dnd.moddo.event.domain.paymentRequest.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class ManagerPaymentRequestNotAllowedException extends ModdoException { + public ManagerPaymentRequestNotAllowedException(Long requestMemberId) { + super(HttpStatus.BAD_REQUEST, "총무는 입금 확인 요청을 보낼 수 없습니다. (Member ID: " + requestMemberId + ")"); + } +} diff --git a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestAlreadyApprovedException.java b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestAlreadyApprovedException.java new file mode 100644 index 00000000..c12da48e --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestAlreadyApprovedException.java @@ -0,0 +1,14 @@ +package com.dnd.moddo.event.domain.paymentRequest.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class PaymentRequestAlreadyApprovedException extends ModdoException { + public PaymentRequestAlreadyApprovedException(Long settlementId, Long requestMemberId) { + super( + HttpStatus.BAD_REQUEST, + "이미 완료된 입금 확인 요청이 있습니다. (Settlement ID: " + settlementId + ", Member ID: " + requestMemberId + ")" + ); + } +} diff --git a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestNotFoundException.java b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestNotFoundException.java new file mode 100644 index 00000000..fc24f372 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestNotFoundException.java @@ -0,0 +1,11 @@ +package com.dnd.moddo.event.domain.paymentRequest.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class PaymentRequestNotFoundException extends ModdoException { + public PaymentRequestNotFoundException(Long paymentRequestId) { + super(HttpStatus.NOT_FOUND, "해당 입금 확인 요청을 찾을 수 없습니다. (PaymentRequest ID: " + paymentRequestId + ")"); + } +} diff --git a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestNotPendingException.java b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestNotPendingException.java new file mode 100644 index 00000000..dc221841 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestNotPendingException.java @@ -0,0 +1,15 @@ +package com.dnd.moddo.event.domain.paymentRequest.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus; + +public class PaymentRequestNotPendingException extends ModdoException { + public PaymentRequestNotPendingException(Long paymentRequestId, PaymentRequestStatus status) { + super( + HttpStatus.BAD_REQUEST, + "처리 대기 상태의 입금 확인 요청만 처리할 수 있습니다. (PaymentRequest ID: " + paymentRequestId + ", Status: " + status + ")" + ); + } +} diff --git a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestUnauthorizedException.java b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestUnauthorizedException.java new file mode 100644 index 00000000..b1aa8afa --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestUnauthorizedException.java @@ -0,0 +1,14 @@ +package com.dnd.moddo.event.domain.paymentRequest.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class PaymentRequestUnauthorizedException extends ModdoException { + public PaymentRequestUnauthorizedException(Long paymentRequestId, Long userId) { + super( + HttpStatus.FORBIDDEN, + "해당 입금 확인 요청을 처리할 권한이 없습니다. (PaymentRequest ID: " + paymentRequestId + ", User ID: " + userId + ")" + ); + } +} diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/MemberExpenseRepository.java b/src/main/java/com/dnd/moddo/event/infrastructure/MemberExpenseRepository.java index 34ab8aec..558fc288 100644 --- a/src/main/java/com/dnd/moddo/event/infrastructure/MemberExpenseRepository.java +++ b/src/main/java/com/dnd/moddo/event/infrastructure/MemberExpenseRepository.java @@ -17,4 +17,7 @@ public interface MemberExpenseRepository extends JpaRepository findAllByAppointmentMemberIds(@Param("memberIds") List memberIds); + + @Query("select me from MemberExpense me where me.member.id = :memberId") + List findByMemberId(@Param("memberId") Long memberId); } diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java b/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java index c60696a6..7424671f 100644 --- a/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java +++ b/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java @@ -1,6 +1,7 @@ package com.dnd.moddo.event.infrastructure; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -22,6 +23,14 @@ select count(gm) > 0 """) boolean existsBySettlementIdAndUserId(@Param("settlementId") Long settlementId, @Param("userId") Long userId); + @Query(""" + select gm + from Member gm + where gm.settlement.id = :settlementId + and gm.user.id = :userId + """) + Optional findBySettlementIdAndUserId(@Param("settlementId") Long settlementId, @Param("userId") Long userId); + default Member getById(Long id) { return findById(id) .orElseThrow(() -> new MemberNotFoundException(id)); diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/PaymentRequestRepository.java b/src/main/java/com/dnd/moddo/event/infrastructure/PaymentRequestRepository.java new file mode 100644 index 00000000..bacebbc8 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/infrastructure/PaymentRequestRepository.java @@ -0,0 +1,34 @@ +package com.dnd.moddo.event.infrastructure; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus; +import com.dnd.moddo.event.domain.paymentRequest.exception.PaymentRequestNotFoundException; + +public interface PaymentRequestRepository extends JpaRepository { + + @Query(""" + select count(pr) > 0 + from PaymentRequest pr + where pr.settlement.id = :settlementId + and pr.requestMember.id = :requestMemberId + and pr.status = :status + """) + boolean existsBySettlementIdAndRequestMemberIdAndStatus( + @Param("settlementId") Long settlementId, + @Param("requestMemberId") Long requestMemberId, + @Param("status") PaymentRequestStatus status + ); + + default PaymentRequest getById(Long paymentRequestId) { + return findById(paymentRequestId) + .orElseThrow(() -> new PaymentRequestNotFoundException(paymentRequestId)); + } + + List findByTargetUserId(Long targetUserId); +} diff --git a/src/main/java/com/dnd/moddo/event/presentation/PaymentRequestController.java b/src/main/java/com/dnd/moddo/event/presentation/PaymentRequestController.java new file mode 100644 index 00000000..b107135d --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/presentation/PaymentRequestController.java @@ -0,0 +1,52 @@ +package com.dnd.moddo.event.presentation; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.dnd.moddo.auth.infrastructure.security.LoginUser; +import com.dnd.moddo.auth.presentation.response.LoginUserInfo; +import com.dnd.moddo.event.application.command.CommandPaymentRequest; +import com.dnd.moddo.event.application.query.QuerySettlementService; +import com.dnd.moddo.event.presentation.response.PaymentRequestResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class PaymentRequestController { + private final CommandPaymentRequest commandPaymentRequest; + private final QuerySettlementService querySettlementService; + + @PostMapping("/groups/{code}/payments") + public ResponseEntity createPaymentRequest( + @PathVariable String code, + @LoginUser LoginUserInfo loginUser + ) { + Long settlementId = querySettlementService.findIdByCode(code); + PaymentRequestResponse response = commandPaymentRequest.createPaymentRequest(settlementId, loginUser.userId()); + return ResponseEntity.ok(response); + } + + @PatchMapping("/payments/{paymentRequestId}/approve") + public ResponseEntity approvePaymentRequest( + @PathVariable Long paymentRequestId, + @LoginUser LoginUserInfo loginUser + ) { + PaymentRequestResponse response = commandPaymentRequest.approvePaymentRequest(paymentRequestId, loginUser.userId()); + return ResponseEntity.ok(response); + } + + @PatchMapping("/payments/{paymentRequestId}/reject") + public ResponseEntity rejectPaymentRequest( + @PathVariable Long paymentRequestId, + @LoginUser LoginUserInfo loginUser + ) { + PaymentRequestResponse response = commandPaymentRequest.rejectPaymentRequest(paymentRequestId, loginUser.userId()); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestResponse.java b/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestResponse.java new file mode 100644 index 00000000..f861b567 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestResponse.java @@ -0,0 +1,28 @@ +package com.dnd.moddo.event.presentation.response; + +import java.time.LocalDateTime; + +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus; + +public record PaymentRequestResponse( + Long id, + Long settlementId, + Long requestMemberId, + Long targetUserId, + LocalDateTime requestedAt, + LocalDateTime processedAt, + PaymentRequestStatus status +) { + public static PaymentRequestResponse of(PaymentRequest paymentRequest) { + return new PaymentRequestResponse( + paymentRequest.getId(), + paymentRequest.getSettlementId(), + paymentRequest.getRequestMemberId(), + paymentRequest.getTargetUserId(), + paymentRequest.getRequestedAt(), + paymentRequest.getProcessedAt(), + paymentRequest.getStatus() + ); + } +} diff --git a/src/main/java/com/dnd/moddo/user/application/impl/UserReader.java b/src/main/java/com/dnd/moddo/user/application/impl/UserReader.java index a6def030..a415aeb2 100644 --- a/src/main/java/com/dnd/moddo/user/application/impl/UserReader.java +++ b/src/main/java/com/dnd/moddo/user/application/impl/UserReader.java @@ -25,8 +25,11 @@ public Optional findKakaoIdById(Long userId) { return userRepository.findKakaoIdById(userId); } + public User read(Long userId) { + return userRepository.getById(userId); + } + public UserResponse findById(Long userId) { - User user = userRepository.getById(userId); - return UserResponse.of(user); + return UserResponse.of(read(userId)); } } diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java new file mode 100644 index 00000000..e3403475 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java @@ -0,0 +1,127 @@ +package com.dnd.moddo.domain.paymentRequest.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; + +import com.dnd.moddo.auth.infrastructure.security.LoginUserArgumentResolver; +import com.dnd.moddo.auth.presentation.response.LoginUserInfo; +import com.dnd.moddo.event.presentation.response.PaymentRequestResponse; +import com.dnd.moddo.global.util.RestDocsTestSupport; + +public class PaymentRequestControllerTest extends RestDocsTestSupport { + + @BeforeEach + void setUpLoginUser() throws Exception { + when(loginUserArgumentResolver.supportsParameter(any())).thenReturn(true); + when(loginUserArgumentResolver.resolveArgument(any(), any(), any(), any())) + .thenReturn(new LoginUserInfo(1L, "USER")); + } + + @Test + @DisplayName("입금 확인 요청을 생성한다.") + void createPaymentRequest() throws Exception { + String code = "code"; + Long settlementId = 1L; + PaymentRequestResponse response = new PaymentRequestResponse( + 1L, settlementId, 2L, 3L, LocalDateTime.of(2026, 3, 13, 22, 0), null, null + ); + + when(querySettlementService.findIdByCode(code)).thenReturn(settlementId); + when(commandPaymentRequest.createPaymentRequest(settlementId, 1L)).thenReturn(response); + + mockMvc.perform(post("/api/v1/groups/{code}/payments", code) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.settlementId").value(settlementId)) + .andDo(restDocs.document( + pathParameters( + parameterWithName("code").description("정산 코드") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("입금 확인 요청 ID"), + fieldWithPath("settlementId").type(JsonFieldType.NUMBER).description("정산 ID"), + fieldWithPath("requestMemberId").type(JsonFieldType.NUMBER).description("요청 참여자 ID"), + fieldWithPath("targetUserId").type(JsonFieldType.NUMBER).description("처리 대상 사용자 ID"), + fieldWithPath("requestedAt").type(JsonFieldType.STRING).description("요청 시각"), + fieldWithPath("processedAt").type(JsonFieldType.NULL).description("처리 시각").optional(), + fieldWithPath("status").type(JsonFieldType.NULL).description("요청 상태").optional() + ) + )); + } + + @Test + @DisplayName("입금 확인 요청을 승인한다.") + void approvePaymentRequest() throws Exception { + PaymentRequestResponse response = new PaymentRequestResponse( + 1L, 1L, 2L, 1L, + LocalDateTime.of(2026, 3, 13, 22, 0), + LocalDateTime.of(2026, 3, 13, 22, 5), + com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus.APPROVED + ); + + when(commandPaymentRequest.approvePaymentRequest(1L, 1L)).thenReturn(response); + + mockMvc.perform(patch("/api/v1/payments/{paymentRequestId}/approve", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("APPROVED")) + .andDo(restDocs.document( + pathParameters( + parameterWithName("paymentRequestId").description("입금 확인 요청 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("입금 확인 요청 ID"), + fieldWithPath("settlementId").type(JsonFieldType.NUMBER).description("정산 ID"), + fieldWithPath("requestMemberId").type(JsonFieldType.NUMBER).description("요청 참여자 ID"), + fieldWithPath("targetUserId").type(JsonFieldType.NUMBER).description("처리 대상 사용자 ID"), + fieldWithPath("requestedAt").type(JsonFieldType.STRING).description("요청 시각"), + fieldWithPath("processedAt").type(JsonFieldType.STRING).description("처리 시각"), + fieldWithPath("status").type(JsonFieldType.STRING).description("요청 상태") + ) + )); + } + + @Test + @DisplayName("입금 확인 요청을 거절한다.") + void rejectPaymentRequest() throws Exception { + PaymentRequestResponse response = new PaymentRequestResponse( + 1L, 1L, 2L, 1L, + LocalDateTime.of(2026, 3, 13, 22, 0), + LocalDateTime.of(2026, 3, 13, 22, 5), + com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus.REJECTED + ); + + when(commandPaymentRequest.rejectPaymentRequest(1L, 1L)).thenReturn(response); + + mockMvc.perform(patch("/api/v1/payments/{paymentRequestId}/reject", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("REJECTED")) + .andDo(restDocs.document( + pathParameters( + parameterWithName("paymentRequestId").description("입금 확인 요청 ID") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("입금 확인 요청 ID"), + fieldWithPath("settlementId").type(JsonFieldType.NUMBER).description("정산 ID"), + fieldWithPath("requestMemberId").type(JsonFieldType.NUMBER).description("요청 참여자 ID"), + fieldWithPath("targetUserId").type(JsonFieldType.NUMBER).description("처리 대상 사용자 ID"), + fieldWithPath("requestedAt").type(JsonFieldType.STRING).description("요청 시각"), + fieldWithPath("processedAt").type(JsonFieldType.STRING).description("처리 시각"), + fieldWithPath("status").type(JsonFieldType.STRING).description("요청 상태") + ) + )); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java new file mode 100644 index 00000000..2f2dd26d --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java @@ -0,0 +1,78 @@ +package com.dnd.moddo.domain.paymentRequest.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.dnd.moddo.event.application.command.CommandPaymentRequest; +import com.dnd.moddo.event.application.impl.PaymentRequestCreator; +import com.dnd.moddo.event.application.impl.PaymentRequestUpdater; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus; +import com.dnd.moddo.event.presentation.response.PaymentRequestResponse; + +@ExtendWith(MockitoExtension.class) +class CommandPaymentRequestTest { + + @Mock + private PaymentRequestCreator paymentRequestCreator; + + @Mock + private PaymentRequestUpdater paymentRequestUpdater; + + @InjectMocks + private CommandPaymentRequest commandPaymentRequest; + + @Test + @DisplayName("입금 확인 요청을 생성할 수 있다.") + void createPaymentRequest() { + PaymentRequest paymentRequest = mock(PaymentRequest.class); + stubPaymentRequest(paymentRequest); + when(paymentRequestCreator.createPaymentRequest(1L, 2L)).thenReturn(paymentRequest); + + PaymentRequestResponse response = commandPaymentRequest.createPaymentRequest(1L, 2L); + + assertThat(response.id()).isEqualTo(1L); + assertThat(response.status()).isEqualTo(PaymentRequestStatus.PENDING); + } + + @Test + @DisplayName("입금 확인 요청을 승인할 수 있다.") + void approvePaymentRequest() { + PaymentRequest paymentRequest = mock(PaymentRequest.class); + stubPaymentRequest(paymentRequest); + when(paymentRequestUpdater.approvePaymentRequest(1L, 2L)).thenReturn(paymentRequest); + + PaymentRequestResponse response = commandPaymentRequest.approvePaymentRequest(1L, 2L); + + assertThat(response.id()).isEqualTo(1L); + assertThat(response.status()).isEqualTo(PaymentRequestStatus.PENDING); + } + + @Test + @DisplayName("입금 확인 요청을 거절할 수 있다.") + void rejectPaymentRequest() { + PaymentRequest paymentRequest = mock(PaymentRequest.class); + stubPaymentRequest(paymentRequest); + when(paymentRequestUpdater.rejectPaymentRequest(1L, 2L)).thenReturn(paymentRequest); + + PaymentRequestResponse response = commandPaymentRequest.rejectPaymentRequest(1L, 2L); + + assertThat(response.id()).isEqualTo(1L); + assertThat(response.status()).isEqualTo(PaymentRequestStatus.PENDING); + } + + private void stubPaymentRequest(PaymentRequest paymentRequest) { + when(paymentRequest.getId()).thenReturn(1L); + when(paymentRequest.getSettlementId()).thenReturn(2L); + when(paymentRequest.getRequestMemberId()).thenReturn(3L); + when(paymentRequest.getTargetUserId()).thenReturn(4L); + when(paymentRequest.getStatus()).thenReturn(PaymentRequestStatus.PENDING); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestCreatorTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestCreatorTest.java new file mode 100644 index 00000000..ebf7631b --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestCreatorTest.java @@ -0,0 +1,80 @@ +package com.dnd.moddo.domain.paymentRequest.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.dnd.moddo.event.application.impl.MemberReader; +import com.dnd.moddo.event.application.impl.PaymentRequestCreator; +import com.dnd.moddo.event.application.impl.PaymentRequestValidator; +import com.dnd.moddo.event.application.impl.SettlementReader; +import com.dnd.moddo.event.domain.member.ExpenseRole; +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus; +import com.dnd.moddo.event.domain.settlement.Settlement; +import com.dnd.moddo.event.infrastructure.PaymentRequestRepository; +import com.dnd.moddo.global.support.UserTestFactory; +import com.dnd.moddo.user.application.impl.UserReader; +import com.dnd.moddo.user.domain.User; + +@ExtendWith(MockitoExtension.class) +class PaymentRequestCreatorTest { + + @Mock + private PaymentRequestRepository paymentRequestRepository; + + @Mock + private MemberReader memberReader; + + @Mock + private SettlementReader settlementReader; + + @Mock + private UserReader userReader; + + @Mock + private PaymentRequestValidator paymentRequestValidator; + + @InjectMocks + private PaymentRequestCreator paymentRequestCreator; + + @Test + @DisplayName("입금 확인 요청을 생성할 수 있다.") + void createPaymentRequestSuccess() { + Long settlementId = 1L; + Long userId = 10L; + Long writerId = 99L; + + Member requestMember = Member.builder() + .name("참여자") + .profileId(1) + .role(ExpenseRole.PARTICIPANT) + .build(); + Settlement settlement = mock(Settlement.class); + User targetUser = UserTestFactory.createWithEmail("writer@test.com"); + + when(memberReader.findBySettlementIdAndUserId(settlementId, userId)).thenReturn(requestMember); + when(settlementReader.read(settlementId)).thenReturn(settlement); + when(settlement.getWriter()).thenReturn(writerId); + when(userReader.read(writerId)).thenReturn(targetUser); + when(paymentRequestRepository.save(any(PaymentRequest.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + PaymentRequest result = paymentRequestCreator.createPaymentRequest(settlementId, userId); + + assertThat(result).isNotNull(); + assertThat(result.getSettlement()).isEqualTo(settlement); + assertThat(result.getRequestMember()).isEqualTo(requestMember); + assertThat(result.getTargetUser()).isEqualTo(targetUser); + assertThat(result.getStatus()).isEqualTo(PaymentRequestStatus.PENDING); + + verify(paymentRequestValidator).validateCreateRequest(settlementId, requestMember); + verify(paymentRequestRepository).save(any(PaymentRequest.class)); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestUpdaterTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestUpdaterTest.java new file mode 100644 index 00000000..6c0e6054 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestUpdaterTest.java @@ -0,0 +1,68 @@ +package com.dnd.moddo.domain.paymentRequest.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.dnd.moddo.event.application.impl.MemberUpdater; +import com.dnd.moddo.event.application.impl.PaymentRequestUpdater; +import com.dnd.moddo.event.application.impl.PaymentRequestValidator; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.infrastructure.PaymentRequestRepository; + +@ExtendWith(MockitoExtension.class) +class PaymentRequestUpdaterTest { + + @Mock + private PaymentRequestRepository paymentRequestRepository; + + @Mock + private PaymentRequestValidator paymentRequestValidator; + + @Mock + private MemberUpdater memberUpdater; + + @InjectMocks + private PaymentRequestUpdater paymentRequestUpdater; + + @Test + @DisplayName("입금 확인 요청을 승인할 수 있다.") + void approvePaymentRequestSuccess() { + Long paymentRequestId = 1L; + Long userId = 100L; + PaymentRequest paymentRequest = mock(PaymentRequest.class); + + when(paymentRequestRepository.getById(paymentRequestId)).thenReturn(paymentRequest); + when(paymentRequest.getRequestMemberId()).thenReturn(10L); + + PaymentRequest result = paymentRequestUpdater.approvePaymentRequest(paymentRequestId, userId); + + assertThat(result).isEqualTo(paymentRequest); + verify(paymentRequestValidator).validateProcessRequest(paymentRequest, userId); + verify(memberUpdater).updatePaymentStatus(10L, true); + verify(paymentRequest).approve(); + } + + @Test + @DisplayName("입금 확인 요청을 거절할 수 있다.") + void rejectPaymentRequestSuccess() { + Long paymentRequestId = 1L; + Long userId = 100L; + PaymentRequest paymentRequest = mock(PaymentRequest.class); + + when(paymentRequestRepository.getById(paymentRequestId)).thenReturn(paymentRequest); + + PaymentRequest result = paymentRequestUpdater.rejectPaymentRequest(paymentRequestId, userId); + + assertThat(result).isEqualTo(paymentRequest); + verify(paymentRequestValidator).validateProcessRequest(paymentRequest, userId); + verify(paymentRequest).reject(); + verify(memberUpdater, never()).updatePaymentStatus(anyLong(), anyBoolean()); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestValidatorTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestValidatorTest.java new file mode 100644 index 00000000..47efb1c7 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestValidatorTest.java @@ -0,0 +1,99 @@ +package com.dnd.moddo.domain.paymentRequest.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.dnd.moddo.event.application.impl.PaymentRequestValidator; +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus; +import com.dnd.moddo.event.domain.paymentRequest.exception.DuplicatePendingPaymentRequestException; +import com.dnd.moddo.event.domain.paymentRequest.exception.ManagerPaymentRequestNotAllowedException; +import com.dnd.moddo.event.domain.paymentRequest.exception.PaymentRequestAlreadyApprovedException; +import com.dnd.moddo.event.domain.paymentRequest.exception.PaymentRequestNotPendingException; +import com.dnd.moddo.event.domain.paymentRequest.exception.PaymentRequestUnauthorizedException; +import com.dnd.moddo.event.infrastructure.PaymentRequestRepository; +import com.dnd.moddo.user.domain.User; + +@ExtendWith(MockitoExtension.class) +class PaymentRequestValidatorTest { + + @Mock + private PaymentRequestRepository paymentRequestRepository; + + @InjectMocks + private PaymentRequestValidator paymentRequestValidator; + + @Test + @DisplayName("총무는 입금 확인 요청을 생성할 수 없다.") + void validateCreateRequestFailWhenManager() { + Member requestMember = mock(Member.class); + when(requestMember.isManager()).thenReturn(true); + when(requestMember.getId()).thenReturn(1L); + + assertThatThrownBy(() -> paymentRequestValidator.validateCreateRequest(1L, requestMember)) + .isInstanceOf(ManagerPaymentRequestNotAllowedException.class); + } + + @Test + @DisplayName("대기 중인 동일 입금 확인 요청이 있으면 생성할 수 없다.") + void validateCreateRequestFailWhenDuplicatePending() { + Member requestMember = mock(Member.class); + when(requestMember.isManager()).thenReturn(false); + when(requestMember.getId()).thenReturn(2L); + when(paymentRequestRepository.existsBySettlementIdAndRequestMemberIdAndStatus(1L, 2L, PaymentRequestStatus.PENDING)) + .thenReturn(true); + + assertThatThrownBy(() -> paymentRequestValidator.validateCreateRequest(1L, requestMember)) + .isInstanceOf(DuplicatePendingPaymentRequestException.class); + } + + @Test + @DisplayName("이미 승인된 입금 확인 요청이 있으면 생성할 수 없다.") + void validateCreateRequestFailWhenAlreadyApproved() { + Member requestMember = mock(Member.class); + when(requestMember.isManager()).thenReturn(false); + when(requestMember.getId()).thenReturn(2L); + when(paymentRequestRepository.existsBySettlementIdAndRequestMemberIdAndStatus(1L, 2L, PaymentRequestStatus.PENDING)) + .thenReturn(false); + when(paymentRequestRepository.existsBySettlementIdAndRequestMemberIdAndStatus(1L, 2L, PaymentRequestStatus.APPROVED)) + .thenReturn(true); + + assertThatThrownBy(() -> paymentRequestValidator.validateCreateRequest(1L, requestMember)) + .isInstanceOf(PaymentRequestAlreadyApprovedException.class); + } + + @Test + @DisplayName("처리 대상 유저가 아니면 승인 또는 거절할 수 없다.") + void validateProcessRequestFailWhenUnauthorized() { + PaymentRequest paymentRequest = mock(PaymentRequest.class); + User targetUser = mock(User.class); + + when(paymentRequest.getStatus()).thenReturn(PaymentRequestStatus.PENDING); + when(paymentRequest.getTargetUser()).thenReturn(targetUser); + when(paymentRequest.getId()).thenReturn(1L); + when(targetUser.getId()).thenReturn(100L); + + assertThatThrownBy(() -> paymentRequestValidator.validateProcessRequest(paymentRequest, 200L)) + .isInstanceOf(PaymentRequestUnauthorizedException.class); + } + + @Test + @DisplayName("대기 상태가 아니면 승인 또는 거절할 수 없다.") + void validateProcessRequestFailWhenNotPending() { + PaymentRequest paymentRequest = mock(PaymentRequest.class); + + when(paymentRequest.getStatus()).thenReturn(PaymentRequestStatus.APPROVED); + when(paymentRequest.getId()).thenReturn(1L); + + assertThatThrownBy(() -> paymentRequestValidator.validateProcessRequest(paymentRequest, 100L)) + .isInstanceOf(PaymentRequestNotPendingException.class); + } +} diff --git a/src/test/java/com/dnd/moddo/global/util/ControllerTest.java b/src/test/java/com/dnd/moddo/global/util/ControllerTest.java index d6223290..eb0ce90b 100644 --- a/src/test/java/com/dnd/moddo/global/util/ControllerTest.java +++ b/src/test/java/com/dnd/moddo/global/util/ControllerTest.java @@ -18,6 +18,7 @@ import com.dnd.moddo.common.logging.ErrorNotifier; import com.dnd.moddo.event.application.command.CommandExpenseService; import com.dnd.moddo.event.application.command.CommandMemberService; +import com.dnd.moddo.event.application.command.CommandPaymentRequest; import com.dnd.moddo.event.application.command.CommandSettlementService; import com.dnd.moddo.event.application.query.QueryExpenseService; import com.dnd.moddo.event.application.query.QueryMemberExpenseService; @@ -26,6 +27,7 @@ import com.dnd.moddo.event.presentation.ExpenseController; import com.dnd.moddo.event.presentation.MemberController; import com.dnd.moddo.event.presentation.MemberExpenseController; +import com.dnd.moddo.event.presentation.PaymentRequestController; import com.dnd.moddo.event.presentation.SettlementController; import com.dnd.moddo.image.application.CommandImageService; import com.dnd.moddo.image.presentation.ImageController; @@ -43,6 +45,7 @@ ExpenseController.class, SettlementController.class, MemberController.class, + PaymentRequestController.class, ImageController.class, MemberExpenseController.class, com.dnd.moddo.user.presentation.UserController.class, @@ -90,6 +93,9 @@ public abstract class ControllerTest { @MockBean protected CommandMemberService commandMemberService; + @MockBean + protected CommandPaymentRequest commandPaymentRequest; + @MockBean protected CommandImageService commandImageService; From 4464d37ca60b373747e4d742bc18f5b9626d4f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 00:25:00 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=B0=B8=EC=97=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0=20=EC=9A=94=EC=B2=AD=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20api=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/impl/MemberExpenseReader.java | 6 +- .../impl/PaymentRequestReader.java | 63 +++++++++++ .../query/QueryPaymentRequestService.java | 18 +++ .../domain/memberExpense/MemberExpense.java | 5 +- .../MemberExpenseRepository.java | 3 + .../PaymentRequestRepository.java | 3 +- .../PaymentRequestController.java | 12 ++ .../response/PaymentRequestItemResponse.java | 12 ++ .../response/PaymentRequestsResponse.java | 11 ++ .../PaymentRequestControllerTest.java | 42 ++++++- .../QueryPaymentRequestServiceTest.java | 43 +++++++ .../PaymentRequestReaderTest.java | 107 ++++++++++++++++++ .../dnd/moddo/global/util/ControllerTest.java | 4 + 13 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java create mode 100644 src/main/java/com/dnd/moddo/event/application/query/QueryPaymentRequestService.java create mode 100644 src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestItemResponse.java create mode 100644 src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestsResponse.java create mode 100644 src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java diff --git a/src/main/java/com/dnd/moddo/event/application/impl/MemberExpenseReader.java b/src/main/java/com/dnd/moddo/event/application/impl/MemberExpenseReader.java index bd47b053..d404d250 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/MemberExpenseReader.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/MemberExpenseReader.java @@ -28,7 +28,11 @@ public List findAllByExpenseIds(List expenseIds) { return memberExpenseRepository.findAllByExpenseIds(expenseIds); } - public List getMemberExpenseByMemberId(Long memberId) { + public List findAllByMemberId(Long memberId) { return memberExpenseRepository.findByMemberId(memberId); } + + public List findAllByMemberIds(List memberIds) { + return memberExpenseRepository.findAllByMemberIds(memberIds); + } } diff --git a/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java new file mode 100644 index 00000000..fe4e6e5e --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java @@ -0,0 +1,63 @@ +package com.dnd.moddo.event.application.impl; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.memberExpense.MemberExpense; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus; +import com.dnd.moddo.event.infrastructure.PaymentRequestRepository; +import com.dnd.moddo.event.presentation.response.PaymentRequestItemResponse; +import com.dnd.moddo.event.presentation.response.PaymentRequestsResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class PaymentRequestReader { + private final PaymentRequestRepository paymentRequestRepository; + private final MemberExpenseReader memberExpenseReader; + + public PaymentRequestsResponse findByTargetUserId(Long targetUserId) { + List paymentRequests = paymentRequestRepository.findByTargetUserId(targetUserId) + .stream().filter(paymentRequest -> paymentRequest.getStatus() == PaymentRequestStatus.PENDING) + .toList(); + + List memberIds = paymentRequests.stream() + .map(PaymentRequest::getRequestMemberId) + .distinct() + .toList(); + + Map amountByMemberId = memberExpenseReader.findAllByMemberIds(memberIds).stream() + .collect(Collectors.groupingBy( + MemberExpense::getMemberId, + Collectors.summingLong(MemberExpense::getAmount) + )); + + List responses = paymentRequests.stream() + .map(paymentRequest -> { + Member member = paymentRequest.getRequestMember(); + Long memberId = member.getId(); + + return new PaymentRequestItemResponse( + paymentRequest.getRequestedAt(), + paymentRequest.getId(), + memberId, + member.getName(), + amountByMemberId.getOrDefault(memberId, 0L) + ); + }) + .sorted(Comparator.comparing(PaymentRequestItemResponse::requestedAt).reversed()) + .toList(); + + return PaymentRequestsResponse.of(responses); + } + +} diff --git a/src/main/java/com/dnd/moddo/event/application/query/QueryPaymentRequestService.java b/src/main/java/com/dnd/moddo/event/application/query/QueryPaymentRequestService.java new file mode 100644 index 00000000..227cf65c --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/application/query/QueryPaymentRequestService.java @@ -0,0 +1,18 @@ +package com.dnd.moddo.event.application.query; + +import org.springframework.stereotype.Service; + +import com.dnd.moddo.event.application.impl.PaymentRequestReader; +import com.dnd.moddo.event.presentation.response.PaymentRequestsResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class QueryPaymentRequestService { + private final PaymentRequestReader paymentRequestReader; + + public PaymentRequestsResponse findByTargetUserId(Long targetUserId) { + return paymentRequestReader.findByTargetUserId(targetUserId); + } +} diff --git a/src/main/java/com/dnd/moddo/event/domain/memberExpense/MemberExpense.java b/src/main/java/com/dnd/moddo/event/domain/memberExpense/MemberExpense.java index cecf884a..f7a5d520 100644 --- a/src/main/java/com/dnd/moddo/event/domain/memberExpense/MemberExpense.java +++ b/src/main/java/com/dnd/moddo/event/domain/memberExpense/MemberExpense.java @@ -44,5 +44,8 @@ public MemberExpense(Long expenseId, Member member, Long amount) { public void updateAmount(Long amount) { this.amount = amount; } -} + public Long getMemberId() { + return member.getId(); + } +} diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/MemberExpenseRepository.java b/src/main/java/com/dnd/moddo/event/infrastructure/MemberExpenseRepository.java index 558fc288..6e1eaf45 100644 --- a/src/main/java/com/dnd/moddo/event/infrastructure/MemberExpenseRepository.java +++ b/src/main/java/com/dnd/moddo/event/infrastructure/MemberExpenseRepository.java @@ -18,6 +18,9 @@ public interface MemberExpenseRepository extends JpaRepository findAllByAppointmentMemberIds(@Param("memberIds") List memberIds); + @Query("select me from MemberExpense me where me.member.id in :memberIds") + List findAllByMemberIds(@Param("memberIds") List memberIds); + @Query("select me from MemberExpense me where me.member.id = :memberId") List findByMemberId(@Param("memberId") Long memberId); } diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/PaymentRequestRepository.java b/src/main/java/com/dnd/moddo/event/infrastructure/PaymentRequestRepository.java index bacebbc8..4b830dec 100644 --- a/src/main/java/com/dnd/moddo/event/infrastructure/PaymentRequestRepository.java +++ b/src/main/java/com/dnd/moddo/event/infrastructure/PaymentRequestRepository.java @@ -30,5 +30,6 @@ default PaymentRequest getById(Long paymentRequestId) { .orElseThrow(() -> new PaymentRequestNotFoundException(paymentRequestId)); } - List findByTargetUserId(Long targetUserId); + @Query("select pr from PaymentRequest pr where pr.targetUser.id = :targetUserId") + List findByTargetUserId(@Param("targetUserId") Long targetUserId); } diff --git a/src/main/java/com/dnd/moddo/event/presentation/PaymentRequestController.java b/src/main/java/com/dnd/moddo/event/presentation/PaymentRequestController.java index b107135d..c18b4401 100644 --- a/src/main/java/com/dnd/moddo/event/presentation/PaymentRequestController.java +++ b/src/main/java/com/dnd/moddo/event/presentation/PaymentRequestController.java @@ -1,6 +1,7 @@ package com.dnd.moddo.event.presentation; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -10,8 +11,10 @@ import com.dnd.moddo.auth.infrastructure.security.LoginUser; import com.dnd.moddo.auth.presentation.response.LoginUserInfo; import com.dnd.moddo.event.application.command.CommandPaymentRequest; +import com.dnd.moddo.event.application.query.QueryPaymentRequestService; import com.dnd.moddo.event.application.query.QuerySettlementService; import com.dnd.moddo.event.presentation.response.PaymentRequestResponse; +import com.dnd.moddo.event.presentation.response.PaymentRequestsResponse; import lombok.RequiredArgsConstructor; @@ -20,8 +23,17 @@ @RequestMapping("/api/v1") public class PaymentRequestController { private final CommandPaymentRequest commandPaymentRequest; + private final QueryPaymentRequestService queryPaymentRequestService; private final QuerySettlementService querySettlementService; + @GetMapping("/payments") + public ResponseEntity getPaymentRequests( + @LoginUser LoginUserInfo loginUser + ) { + PaymentRequestsResponse response = queryPaymentRequestService.findByTargetUserId(loginUser.userId()); + return ResponseEntity.ok(response); + } + @PostMapping("/groups/{code}/payments") public ResponseEntity createPaymentRequest( @PathVariable String code, diff --git a/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestItemResponse.java b/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestItemResponse.java new file mode 100644 index 00000000..560d4823 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestItemResponse.java @@ -0,0 +1,12 @@ +package com.dnd.moddo.event.presentation.response; + +import java.time.LocalDateTime; + +public record PaymentRequestItemResponse( + LocalDateTime requestedAt, + Long paymentRequestId, + Long memberId, + String name, + Long totalAmount +) { +} diff --git a/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestsResponse.java b/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestsResponse.java new file mode 100644 index 00000000..b1e142fd --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestsResponse.java @@ -0,0 +1,11 @@ +package com.dnd.moddo.event.presentation.response; + +import java.util.List; + +public record PaymentRequestsResponse( + List paymentRequests +) { + public static PaymentRequestsResponse of(List paymentRequests) { + return new PaymentRequestsResponse(paymentRequests); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java index e3403475..db6333ed 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java @@ -18,7 +18,9 @@ import com.dnd.moddo.auth.infrastructure.security.LoginUserArgumentResolver; import com.dnd.moddo.auth.presentation.response.LoginUserInfo; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus; import com.dnd.moddo.event.presentation.response.PaymentRequestResponse; +import com.dnd.moddo.event.presentation.response.PaymentRequestsResponse; import com.dnd.moddo.global.util.RestDocsTestSupport; public class PaymentRequestControllerTest extends RestDocsTestSupport { @@ -30,13 +32,49 @@ void setUpLoginUser() throws Exception { .thenReturn(new LoginUserInfo(1L, "USER")); } + @Test + @DisplayName("내게 온 입금 확인 요청 목록을 조회한다.") + void getPaymentRequests() throws Exception { + PaymentRequestsResponse response = new PaymentRequestsResponse( + java.util.List.of( + new com.dnd.moddo.event.presentation.response.PaymentRequestItemResponse( + LocalDateTime.of(2026, 3, 13, 22, 0), + 1L, + 2L, + "김반숙", + 12000L + ) + ) + ); + + when(queryPaymentRequestService.findByTargetUserId(1L)).thenReturn(response); + + mockMvc.perform(get("/api/v1/payments")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.paymentRequests").isArray()) + .andExpect(jsonPath("$.paymentRequests[0].paymentRequestId").value(1L)) + .andExpect(jsonPath("$.paymentRequests[0].name").value("김반숙")) + .andExpect(jsonPath("$.paymentRequests[0].totalAmount").value(12000L)) + .andDo(restDocs.document( + responseFields( + fieldWithPath("paymentRequests").type(JsonFieldType.ARRAY).description("입금 확인 요청 목록"), + fieldWithPath("paymentRequests[].requestedAt").type(JsonFieldType.STRING).description("요청 시각"), + fieldWithPath("paymentRequests[].paymentRequestId").type(JsonFieldType.NUMBER) + .description("입금 확인 요청 ID"), + fieldWithPath("paymentRequests[].memberId").type(JsonFieldType.NUMBER).description("요청 참여자 ID"), + fieldWithPath("paymentRequests[].name").type(JsonFieldType.STRING).description("요청 참여자 이름"), + fieldWithPath("paymentRequests[].totalAmount").type(JsonFieldType.NUMBER).description("요청 금액") + ) + )); + } + @Test @DisplayName("입금 확인 요청을 생성한다.") void createPaymentRequest() throws Exception { String code = "code"; Long settlementId = 1L; PaymentRequestResponse response = new PaymentRequestResponse( - 1L, settlementId, 2L, 3L, LocalDateTime.of(2026, 3, 13, 22, 0), null, null + 1L, settlementId, 2L, 3L, LocalDateTime.of(2026, 3, 13, 22, 0), null, PaymentRequestStatus.PENDING ); when(querySettlementService.findIdByCode(code)).thenReturn(settlementId); @@ -58,7 +96,7 @@ void createPaymentRequest() throws Exception { fieldWithPath("targetUserId").type(JsonFieldType.NUMBER).description("처리 대상 사용자 ID"), fieldWithPath("requestedAt").type(JsonFieldType.STRING).description("요청 시각"), fieldWithPath("processedAt").type(JsonFieldType.NULL).description("처리 시각").optional(), - fieldWithPath("status").type(JsonFieldType.NULL).description("요청 상태").optional() + fieldWithPath("status").type(JsonFieldType.STRING).description("요청 상태") ) )); } diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java new file mode 100644 index 00000000..8c284381 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java @@ -0,0 +1,43 @@ +package com.dnd.moddo.domain.paymentRequest.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.dnd.moddo.event.application.impl.PaymentRequestReader; +import com.dnd.moddo.event.application.query.QueryPaymentRequestService; +import com.dnd.moddo.event.presentation.response.PaymentRequestItemResponse; +import com.dnd.moddo.event.presentation.response.PaymentRequestsResponse; + +@ExtendWith(MockitoExtension.class) +class QueryPaymentRequestServiceTest { + + @Mock + private PaymentRequestReader paymentRequestReader; + + @InjectMocks + private QueryPaymentRequestService queryPaymentRequestService; + + @Test + @DisplayName("대상 유저 기준으로 입금 확인 요청 목록을 조회할 수 있다.") + void findByTargetUserId() { + PaymentRequestsResponse expected = new PaymentRequestsResponse( + List.of(new PaymentRequestItemResponse(LocalDateTime.now(), 1L, 2L, "김반숙", 10000L)) + ); + when(paymentRequestReader.findByTargetUserId(1L)).thenReturn(expected); + + PaymentRequestsResponse result = queryPaymentRequestService.findByTargetUserId(1L); + + assertThat(result).isEqualTo(expected); + verify(paymentRequestReader).findByTargetUserId(1L); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java new file mode 100644 index 00000000..23ad1ebc --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java @@ -0,0 +1,107 @@ +package com.dnd.moddo.domain.paymentRequest.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.dnd.moddo.event.application.impl.MemberExpenseReader; +import com.dnd.moddo.event.application.impl.PaymentRequestReader; +import com.dnd.moddo.event.domain.member.ExpenseRole; +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.memberExpense.MemberExpense; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.domain.settlement.Settlement; +import com.dnd.moddo.event.infrastructure.PaymentRequestRepository; +import com.dnd.moddo.event.presentation.response.PaymentRequestItemResponse; +import com.dnd.moddo.event.presentation.response.PaymentRequestsResponse; + +@ExtendWith(MockitoExtension.class) +class PaymentRequestReaderTest { + + @Mock + private PaymentRequestRepository paymentRequestRepository; + + @Mock + private MemberExpenseReader memberExpenseReader; + + @InjectMocks + private PaymentRequestReader paymentRequestReader; + + @Test + @DisplayName("대상 유저 기준으로 입금 확인 요청 목록을 요청 시각 역순으로 조회할 수 있다.") + void findByTargetUserId() { + Settlement settlement = mock(Settlement.class); + Member member1 = Member.builder() + .name("김반숙") + .profileId(1) + .settlement(settlement) + .role(ExpenseRole.PARTICIPANT) + .build(); + Member member2 = Member.builder() + .name("김모또") + .profileId(2) + .settlement(settlement) + .role(ExpenseRole.PARTICIPANT) + .build(); + + PaymentRequest paymentRequest1 = PaymentRequest.builder() + .settlement(settlement) + .requestMember(member1) + .targetUser(mock(com.dnd.moddo.user.domain.User.class)) + .build(); + PaymentRequest paymentRequest2 = PaymentRequest.builder() + .settlement(settlement) + .requestMember(member2) + .targetUser(mock(com.dnd.moddo.user.domain.User.class)) + .build(); + + setField(paymentRequest1, "id", 1L); + setField(paymentRequest2, "id", 2L); + setField(paymentRequest1, "requestedAt", LocalDateTime.of(2026, 3, 13, 21, 0)); + setField(paymentRequest2, "requestedAt", LocalDateTime.of(2026, 3, 13, 22, 0)); + setField(member1, "id", 11L); + setField(member2, "id", 12L); + + List memberExpenses = List.of( + MemberExpense.builder().expenseId(1L).member(member1).amount(3000L).build(), + MemberExpense.builder().expenseId(2L).member(member1).amount(2000L).build(), + MemberExpense.builder().expenseId(3L).member(member2).amount(7000L).build() + ); + + when(paymentRequestRepository.findByTargetUserId(1L)).thenReturn(List.of(paymentRequest1, paymentRequest2)); + when(memberExpenseReader.findAllByMemberIds(List.of(11L, 12L))).thenReturn(memberExpenses); + + PaymentRequestsResponse result = paymentRequestReader.findByTargetUserId(1L); + + assertThat(result.paymentRequests()).hasSize(2); + PaymentRequestItemResponse first = result.paymentRequests().get(0); + PaymentRequestItemResponse second = result.paymentRequests().get(1); + + assertThat(first.paymentRequestId()).isEqualTo(2L); + assertThat(first.name()).isEqualTo("김모또"); + assertThat(first.totalAmount()).isEqualTo(7000L); + + assertThat(second.paymentRequestId()).isEqualTo(1L); + assertThat(second.name()).isEqualTo("김반숙"); + assertThat(second.totalAmount()).isEqualTo(5000L); + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/com/dnd/moddo/global/util/ControllerTest.java b/src/test/java/com/dnd/moddo/global/util/ControllerTest.java index eb0ce90b..0366172b 100644 --- a/src/test/java/com/dnd/moddo/global/util/ControllerTest.java +++ b/src/test/java/com/dnd/moddo/global/util/ControllerTest.java @@ -23,6 +23,7 @@ import com.dnd.moddo.event.application.query.QueryExpenseService; import com.dnd.moddo.event.application.query.QueryMemberExpenseService; import com.dnd.moddo.event.application.query.QueryMemberService; +import com.dnd.moddo.event.application.query.QueryPaymentRequestService; import com.dnd.moddo.event.application.query.QuerySettlementService; import com.dnd.moddo.event.presentation.ExpenseController; import com.dnd.moddo.event.presentation.MemberController; @@ -96,6 +97,9 @@ public abstract class ControllerTest { @MockBean protected CommandPaymentRequest commandPaymentRequest; + @MockBean + protected QueryPaymentRequestService queryPaymentRequestService; + @MockBean protected CommandImageService commandImageService; From a25a811a1c737e37285d17aba75344c59e162ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 00:31:32 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=B0=B8=EC=97=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0=20=EC=9A=94=EC=B2=AD=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20=EB=A9=A4=EB=B2=84=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=9E=85=EA=B8=88=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20api=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/index.adoc | 2 + src/docs/asciidoc/payment.adoc | 105 ++++++++++++++++++ .../impl/PaymentRequestReader.java | 1 + .../response/PaymentRequestItemResponse.java | 1 + .../PaymentRequestControllerTest.java | 2 + .../QueryPaymentRequestServiceTest.java | 9 +- .../PaymentRequestReaderTest.java | 5 + 7 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/docs/asciidoc/payment.adoc diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 80b8468c..058e27d6 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -22,6 +22,8 @@ include::settlement.adoc[] include::member.adoc[] +include::payment.adoc[] + include::image.adoc[] include::memberExpenses.adoc[] diff --git a/src/docs/asciidoc/payment.adoc b/src/docs/asciidoc/payment.adoc new file mode 100644 index 00000000..7c7f9191 --- /dev/null +++ b/src/docs/asciidoc/payment.adoc @@ -0,0 +1,105 @@ += 입금 확인 요청 (PaymentRequest) +:toc: left +:toclevels: 2 + +== 입금 확인 요청 목록 조회 + +로그인한 사용자를 대상으로 들어온 입금 확인 요청 목록을 조회할 수 있습니다. + +=== Example + +include::{snippets}/payment-request-controller-test/get-payment-requests/curl-request.adoc[] + +=== HTTP + +==== 요청 + +include::{snippets}/payment-request-controller-test/get-payment-requests/http-request.adoc[] + +==== 응답 + +include::{snippets}/payment-request-controller-test/get-payment-requests/http-response.adoc[] + +=== Body + +==== 응답 + +include::{snippets}/payment-request-controller-test/get-payment-requests/response-body.adoc[] + +== 입금 확인 요청 생성 + +정산 참여자가 총무에게 입금 확인 요청을 보낼 수 있습니다. + +=== Example + +include::{snippets}/payment-request-controller-test/create-payment-request/curl-request.adoc[] + +=== HTTP + +==== 요청 + +include::{snippets}/payment-request-controller-test/create-payment-request/http-request.adoc[] + +include::{snippets}/payment-request-controller-test/create-payment-request/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/payment-request-controller-test/create-payment-request/http-response.adoc[] + +=== Body + +==== 응답 + +include::{snippets}/payment-request-controller-test/create-payment-request/response-body.adoc[] + +== 입금 확인 요청 승인 + +총무가 입금 확인 요청을 승인할 수 있습니다. + +=== Example + +include::{snippets}/payment-request-controller-test/approve-payment-request/curl-request.adoc[] + +=== HTTP + +==== 요청 + +include::{snippets}/payment-request-controller-test/approve-payment-request/http-request.adoc[] + +include::{snippets}/payment-request-controller-test/approve-payment-request/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/payment-request-controller-test/approve-payment-request/http-response.adoc[] + +=== Body + +==== 응답 + +include::{snippets}/payment-request-controller-test/approve-payment-request/response-body.adoc[] + +== 입금 확인 요청 거절 + +총무가 입금 확인 요청을 거절할 수 있습니다. + +=== Example + +include::{snippets}/payment-request-controller-test/reject-payment-request/curl-request.adoc[] + +=== HTTP + +==== 요청 + +include::{snippets}/payment-request-controller-test/reject-payment-request/http-request.adoc[] + +include::{snippets}/payment-request-controller-test/reject-payment-request/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/payment-request-controller-test/reject-payment-request/http-response.adoc[] + +=== Body + +==== 응답 + +include::{snippets}/payment-request-controller-test/reject-payment-request/response-body.adoc[] diff --git a/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java index fe4e6e5e..1770a5b2 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java @@ -51,6 +51,7 @@ public PaymentRequestsResponse findByTargetUserId(Long targetUserId) { paymentRequest.getId(), memberId, member.getName(), + member.getProfileUrl(), amountByMemberId.getOrDefault(memberId, 0L) ); }) diff --git a/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestItemResponse.java b/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestItemResponse.java index 560d4823..07b5b182 100644 --- a/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestItemResponse.java +++ b/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestItemResponse.java @@ -7,6 +7,7 @@ public record PaymentRequestItemResponse( Long paymentRequestId, Long memberId, String name, + String profileUrl, Long totalAmount ) { } diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java index db6333ed..7ccdd2e7 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java @@ -42,6 +42,7 @@ void getPaymentRequests() throws Exception { 1L, 2L, "김반숙", + "https://moddo-s3.s3.amazonaws.com/profile/1.png", 12000L ) ) @@ -63,6 +64,7 @@ void getPaymentRequests() throws Exception { .description("입금 확인 요청 ID"), fieldWithPath("paymentRequests[].memberId").type(JsonFieldType.NUMBER).description("요청 참여자 ID"), fieldWithPath("paymentRequests[].name").type(JsonFieldType.STRING).description("요청 참여자 이름"), + fieldWithPath("paymentRequests[].profileUrl").type(JsonFieldType.STRING).description("요청 참여자 프로필 URL"), fieldWithPath("paymentRequests[].totalAmount").type(JsonFieldType.NUMBER).description("요청 금액") ) )); diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java index 8c284381..2255fdf0 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java @@ -31,7 +31,14 @@ class QueryPaymentRequestServiceTest { @DisplayName("대상 유저 기준으로 입금 확인 요청 목록을 조회할 수 있다.") void findByTargetUserId() { PaymentRequestsResponse expected = new PaymentRequestsResponse( - List.of(new PaymentRequestItemResponse(LocalDateTime.now(), 1L, 2L, "김반숙", 10000L)) + List.of(new PaymentRequestItemResponse( + LocalDateTime.now(), + 1L, + 2L, + "김반숙", + "https://moddo-s3.s3.amazonaws.com/profile/1.png", + 10000L + )) ); when(paymentRequestReader.findByTargetUserId(1L)).thenReturn(expected); diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java index 23ad1ebc..e4cad15f 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java @@ -19,6 +19,7 @@ import com.dnd.moddo.event.domain.member.Member; import com.dnd.moddo.event.domain.memberExpense.MemberExpense; import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.event.infrastructure.PaymentRequestRepository; import com.dnd.moddo.event.presentation.response.PaymentRequestItemResponse; @@ -68,6 +69,8 @@ void findByTargetUserId() { setField(paymentRequest2, "id", 2L); setField(paymentRequest1, "requestedAt", LocalDateTime.of(2026, 3, 13, 21, 0)); setField(paymentRequest2, "requestedAt", LocalDateTime.of(2026, 3, 13, 22, 0)); + setField(paymentRequest1, "status", PaymentRequestStatus.PENDING); + setField(paymentRequest2, "status", PaymentRequestStatus.PENDING); setField(member1, "id", 11L); setField(member2, "id", 12L); @@ -88,10 +91,12 @@ void findByTargetUserId() { assertThat(first.paymentRequestId()).isEqualTo(2L); assertThat(first.name()).isEqualTo("김모또"); + assertThat(first.profileUrl()).isEqualTo("https://moddo-s3.s3.amazonaws.com/profile/2.png"); assertThat(first.totalAmount()).isEqualTo(7000L); assertThat(second.paymentRequestId()).isEqualTo(1L); assertThat(second.name()).isEqualTo("김반숙"); + assertThat(second.profileUrl()).isEqualTo("https://moddo-s3.s3.amazonaws.com/profile/1.png"); assertThat(second.totalAmount()).isEqualTo(5000L); } From 166c7e47e917e9c4f0a7aa09b65512abd2c73235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 01:18:42 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EC=B0=B8=EC=97=AC=EC=9E=90=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=A0=84=EB=9E=B5=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20PaymentRequest=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=9D=BC=EB=B6=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/impl/MemberExpenseReader.java | 3 + .../event/application/impl/MemberUpdater.java | 25 ++-- .../impl/PaymentRequestReader.java | 2 +- .../dnd/moddo/event/domain/member/Member.java | 8 +- .../MemberAlreadyAssignedException.java | 4 +- .../exception/MemberNotFoundException.java | 4 +- .../domain/paymentRequest/PaymentRequest.java | 14 +- ...agerPaymentRequestNotAllowedException.java | 2 +- .../PaymentRequestUnauthorizedException.java | 2 +- .../implementation/MemberReaderTest.java | 36 ++++- .../implementation/MemberUpdaterTest.java | 59 ++++++-- .../MemberExpenseReaderTest.java | 51 +++++++ .../PaymentRequestControllerTest.java | 3 +- .../entity/PaymentRequestTest.java | 129 ++++++++++++++++++ .../service/CommandPaymentRequestTest.java | 14 +- .../QueryPaymentRequestServiceTest.java | 2 +- .../PaymentRequestReaderTest.java | 4 +- 17 files changed, 317 insertions(+), 45 deletions(-) create mode 100644 src/test/java/com/dnd/moddo/domain/paymentRequest/entity/PaymentRequestTest.java diff --git a/src/main/java/com/dnd/moddo/event/application/impl/MemberExpenseReader.java b/src/main/java/com/dnd/moddo/event/application/impl/MemberExpenseReader.java index d404d250..b84b429b 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/MemberExpenseReader.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/MemberExpenseReader.java @@ -33,6 +33,9 @@ public List findAllByMemberId(Long memberId) { } public List findAllByMemberIds(List memberIds) { + if (memberIds == null || memberIds.isEmpty()) { + return List.of(); + } return memberExpenseRepository.findAllByMemberIds(memberIds); } } diff --git a/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java b/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java index c730093b..48008c39 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java @@ -16,7 +16,6 @@ import com.dnd.moddo.event.domain.member.exception.MemberSelectionNotAllowedException; import com.dnd.moddo.event.domain.member.exception.MemberSelectionUnauthorizedException; import com.dnd.moddo.event.domain.member.exception.PaymentConcurrencyException; -import com.dnd.moddo.event.domain.member.exception.UserAlreadyAssignedException; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.event.infrastructure.MemberRepository; import com.dnd.moddo.event.presentation.request.MemberSaveRequest; @@ -80,20 +79,24 @@ public Member updatePaymentStatus(Long appointmentMemberId, boolean isPaid) { @Transactional public Member assignMember(Long settlementId, Long memberId, Long userId) { - Member member = memberRepository.getById(memberId); - validateMemberBelongsToSettlement(member, settlementId); - validateSelectable(member); + Member targetMember = memberRepository.getById(memberId); + validateMemberBelongsToSettlement(targetMember, settlementId); + validateSelectable(targetMember); - if (memberRepository.existsBySettlementIdAndUserId(settlementId, userId)) { - throw new UserAlreadyAssignedException(userId); - } - if (member.isAssigned()) { - throw new MemberAlreadyAssignedException(memberId); + if (targetMember.isAssigned()) { + if (targetMember.isAssignedTo(userId)) { + return targetMember; + } + throw new MemberAlreadyAssignedException(); } + memberRepository.findBySettlementIdAndUserId(settlementId, userId) + .ifPresent(member -> member.unassignUser(userId)); + User user = userRepository.getById(userId); - member.assignUser(user); - return memberRepository.save(member); + targetMember.assignUser(user); + + return memberRepository.save(targetMember); } @Transactional diff --git a/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java index 1770a5b2..9deb063e 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java @@ -38,7 +38,7 @@ public PaymentRequestsResponse findByTargetUserId(Long targetUserId) { Map amountByMemberId = memberExpenseReader.findAllByMemberIds(memberIds).stream() .collect(Collectors.groupingBy( MemberExpense::getMemberId, - Collectors.summingLong(MemberExpense::getAmount) + Collectors.summingLong(me -> me.getAmount() != null ? me.getAmount() : 0L) )); List responses = paymentRequests.stream() diff --git a/src/main/java/com/dnd/moddo/event/domain/member/Member.java b/src/main/java/com/dnd/moddo/event/domain/member/Member.java index aedcf418..fef24629 100644 --- a/src/main/java/com/dnd/moddo/event/domain/member/Member.java +++ b/src/main/java/com/dnd/moddo/event/domain/member/Member.java @@ -17,6 +17,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import jakarta.persistence.Version; import lombok.AccessLevel; import lombok.Builder; @@ -25,7 +26,12 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@Table(name = "members") +@Table( + name = "members", + uniqueConstraints = { + @UniqueConstraint(name = "uk_members_settlement_user", columnNames = {"settlement_id", "user_id"}) + } +) @Entity public class Member { diff --git a/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberAlreadyAssignedException.java b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberAlreadyAssignedException.java index f190934f..638e195a 100644 --- a/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberAlreadyAssignedException.java +++ b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberAlreadyAssignedException.java @@ -5,7 +5,7 @@ import com.dnd.moddo.common.exception.ModdoException; public class MemberAlreadyAssignedException extends ModdoException { - public MemberAlreadyAssignedException(Long memberId) { - super(HttpStatus.BAD_REQUEST, "이미 다른 사용자가 선택한 참여자입니다. (Member ID: " + memberId + ")"); + public MemberAlreadyAssignedException() { + super(HttpStatus.BAD_REQUEST, "이미 다른 사용자가 선택한 참여자입니다."); } } diff --git a/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberNotFoundException.java b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberNotFoundException.java index e509947a..b2c9c366 100644 --- a/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberNotFoundException.java +++ b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberNotFoundException.java @@ -5,7 +5,7 @@ import com.dnd.moddo.common.exception.ModdoException; public class MemberNotFoundException extends ModdoException { - public MemberNotFoundException(Long appointmentMemberId) { - super(HttpStatus.NOT_FOUND, "해당 참여자를 찾을 수 없습니다. (AppointmentMember ID: " + appointmentMemberId + ")"); + public MemberNotFoundException(Long memberId) { + super(HttpStatus.NOT_FOUND, "해당 참여자를 찾을 수 없습니다. (MEMBER ID: " + memberId + ")"); } } diff --git a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequest.java b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequest.java index 41237b9e..3b75d095 100644 --- a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequest.java +++ b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequest.java @@ -32,15 +32,15 @@ public class PaymentRequest { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "settlement_id") + @JoinColumn(name = "settlement_id", nullable = false) private Settlement settlement; @ManyToOne - @JoinColumn(name = "request_member_id") + @JoinColumn(name = "request_member_id", nullable = false) private Member requestMember; @ManyToOne - @JoinColumn(name = "target_user_id") + @JoinColumn(name = "target_user_id", nullable = false) private User targetUser; private LocalDateTime requestedAt; @@ -59,12 +59,20 @@ public PaymentRequest(Settlement settlement, Member requestMember, User targetUs this.status = PaymentRequestStatus.PENDING; } + private void assertPending() { + if (this.status != PaymentRequestStatus.PENDING) { + throw new IllegalStateException("이미 처리된 입금 요청입니다."); + } + } + public void approve() { + assertPending(); this.status = PaymentRequestStatus.APPROVED; this.processedAt = LocalDateTime.now(); } public void reject() { + assertPending(); this.status = PaymentRequestStatus.REJECTED; this.processedAt = LocalDateTime.now(); } diff --git a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/ManagerPaymentRequestNotAllowedException.java b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/ManagerPaymentRequestNotAllowedException.java index 833d226a..36d4bc7d 100644 --- a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/ManagerPaymentRequestNotAllowedException.java +++ b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/ManagerPaymentRequestNotAllowedException.java @@ -6,6 +6,6 @@ public class ManagerPaymentRequestNotAllowedException extends ModdoException { public ManagerPaymentRequestNotAllowedException(Long requestMemberId) { - super(HttpStatus.BAD_REQUEST, "총무는 입금 확인 요청을 보낼 수 없습니다. (Member ID: " + requestMemberId + ")"); + super(HttpStatus.FORBIDDEN, "정산 담당자는 입금 확인 요청을 보낼 수 없습니다."); } } diff --git a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestUnauthorizedException.java b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestUnauthorizedException.java index b1aa8afa..2764aeb9 100644 --- a/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestUnauthorizedException.java +++ b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/exception/PaymentRequestUnauthorizedException.java @@ -8,7 +8,7 @@ public class PaymentRequestUnauthorizedException extends ModdoException { public PaymentRequestUnauthorizedException(Long paymentRequestId, Long userId) { super( HttpStatus.FORBIDDEN, - "해당 입금 확인 요청을 처리할 권한이 없습니다. (PaymentRequest ID: " + paymentRequestId + ", User ID: " + userId + ")" + "해당 입금 확인 요청을 처리할 권한이 없습니다." ); } } diff --git a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java index ecb6d121..2a3c6b7e 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java @@ -23,6 +23,8 @@ import com.dnd.moddo.event.infrastructure.MemberRepository; import com.dnd.moddo.global.support.GroupTestFactory; +import java.util.Optional; + @ExtendWith(MockitoExtension.class) public class MemberReaderTest { @Mock @@ -131,7 +133,7 @@ void findByGroupMemberIdFail() { //when & then assertThatThrownBy(() -> { memberReader.findByAppointmentMemberId(appointmentMember); - }).hasMessage("해당 참여자를 찾을 수 없습니다. (AppointmentMember ID: " + appointmentMember + ")"); + }).hasMessage("해당 참여자를 찾을 수 없습니다. (MEMBER ID: " + appointmentMember + ")"); } @DisplayName("정산 ID로 참여자 ID 목록을 조회할 수 있다.") @@ -148,4 +150,36 @@ void findIdsBySettlementIdSuccess() { verify(memberRepository).findMemberIdsBySettlementId(groupId); } + @DisplayName("정산 ID와 사용자 ID로 참여자를 조회할 수 있다.") + @Test + void findBySettlementIdAndUserIdSuccess() { + Long settlementId = 1L; + Long userId = 2L; + Member expectedMember = Member.builder() + .name("김반숙") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .isPaid(false) + .build(); + + when(memberRepository.findBySettlementIdAndUserId(settlementId, userId)).thenReturn(Optional.of(expectedMember)); + + Member result = memberReader.findBySettlementIdAndUserId(settlementId, userId); + + assertThat(result).isEqualTo(expectedMember); + verify(memberRepository).findBySettlementIdAndUserId(settlementId, userId); + } + + @DisplayName("정산 ID와 사용자 ID로 참여자를 찾지 못하면 예외가 발생한다.") + @Test + void findBySettlementIdAndUserIdFail() { + Long settlementId = 1L; + Long userId = 2L; + + when(memberRepository.findBySettlementIdAndUserId(settlementId, userId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> memberReader.findBySettlementIdAndUserId(settlementId, userId)) + .isInstanceOf(MemberNotFoundException.class); + } + } diff --git a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberUpdaterTest.java b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberUpdaterTest.java index 9b68694b..194a130e 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberUpdaterTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberUpdaterTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import java.util.Optional; import java.util.ArrayList; import java.util.List; @@ -29,7 +30,6 @@ import com.dnd.moddo.event.domain.member.exception.MemberSelectionNotAllowedException; import com.dnd.moddo.event.domain.member.exception.MemberSelectionUnauthorizedException; import com.dnd.moddo.event.domain.member.exception.PaymentConcurrencyException; -import com.dnd.moddo.event.domain.member.exception.UserAlreadyAssignedException; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.event.infrastructure.MemberRepository; import com.dnd.moddo.event.presentation.request.MemberSaveRequest; @@ -216,7 +216,7 @@ void assignMemberSuccess() { when(member.isInSettlement(settlementId)).thenReturn(true); when(member.isManager()).thenReturn(false); when(member.isAssigned()).thenReturn(false); - when(memberRepository.existsBySettlementIdAndUserId(settlementId, userId)).thenReturn(false); + when(memberRepository.findBySettlementIdAndUserId(settlementId, userId)).thenReturn(Optional.empty()); when(userRepository.getById(userId)).thenReturn(user); when(memberRepository.save(member)).thenReturn(member); @@ -224,6 +224,7 @@ void assignMemberSuccess() { assertThat(result).isEqualTo(member); verify(member).assignUser(user); + verify(memberRepository).findBySettlementIdAndUserId(settlementId, userId); verify(memberRepository).save(member); } @@ -243,7 +244,7 @@ void assignMemberFailWhenInvalidSettlement() { .isInstanceOf(InvalidMemberException.class); } - @DisplayName("정산 담당는 선택할 수 없다.") + @DisplayName("총무는 선택할 수 없다.") @Test void assignMemberFailWhenManager() { Long settlementId = 1L; @@ -260,26 +261,37 @@ void assignMemberFailWhenManager() { .isInstanceOf(MemberSelectionNotAllowedException.class); } - @DisplayName("이미 같은 정산의 다른 참여자를 선택한 사용자는 예외가 발생한다.") + @DisplayName("기존에 선택한 참여자가 있으면 자동으로 해제하고 새 참여자로 교체한다.") @Test - void assignMemberFailWhenUserAlreadyAssigned() { + void assignMemberReplaceExistingSelection() { Long settlementId = 1L; Long memberId = 2L; Long userId = 3L; + User user = UserTestFactory.createWithEmail("assign@test.com"); Member member = mock(Member.class); + Member assignedMember = mock(Member.class); when(memberRepository.getById(memberId)).thenReturn(member); when(member.isInSettlement(settlementId)).thenReturn(true); when(member.isManager()).thenReturn(false); - when(memberRepository.existsBySettlementIdAndUserId(settlementId, userId)).thenReturn(true); + when(member.isAssigned()).thenReturn(false); + when(memberRepository.findBySettlementIdAndUserId(settlementId, userId)) + .thenReturn(Optional.of(assignedMember)); + when(userRepository.getById(userId)).thenReturn(user); + when(memberRepository.save(member)).thenReturn(member); - assertThatThrownBy(() -> memberUpdater.assignMember(settlementId, memberId, userId)) - .isInstanceOf(UserAlreadyAssignedException.class); + Member result = memberUpdater.assignMember(settlementId, memberId, userId); + + assertThat(result).isEqualTo(member); + verify(assignedMember).unassignUser(userId); + verify(member).assignUser(user); + verify(memberRepository).findBySettlementIdAndUserId(settlementId, userId); + verify(memberRepository).save(member); } - @DisplayName("이미 선택된 참여자를 다시 선택하면 예외가 발생한다.") + @DisplayName("이미 본인이 선택한 참여자를 다시 선택하면 그대로 반환한다.") @Test - void assignMemberFailWhenMemberAlreadyAssigned() { + void assignMemberWhenAlreadyAssignedToMe() { Long settlementId = 1L; Long memberId = 2L; Long userId = 3L; @@ -289,10 +301,35 @@ void assignMemberFailWhenMemberAlreadyAssigned() { when(member.isInSettlement(settlementId)).thenReturn(true); when(member.isManager()).thenReturn(false); when(member.isAssigned()).thenReturn(true); - when(memberRepository.existsBySettlementIdAndUserId(settlementId, userId)).thenReturn(false); + when(member.isAssignedTo(userId)).thenReturn(true); + + Member result = memberUpdater.assignMember(settlementId, memberId, userId); + + assertThat(result).isEqualTo(member); + verify(memberRepository, never()).findBySettlementIdAndUserId(anyLong(), anyLong()); + verify(memberRepository, never()).save(any()); + verify(member, never()).assignUser(any()); + } + + @DisplayName("이미 다른 사용자가 선택한 참여자를 선택하면 예외가 발생한다.") + @Test + void assignMemberFailWhenMemberAlreadyAssignedToAnotherUser() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + Member member = mock(Member.class); + + when(memberRepository.getById(memberId)).thenReturn(member); + when(member.isInSettlement(settlementId)).thenReturn(true); + when(member.isManager()).thenReturn(false); + when(member.isAssigned()).thenReturn(true); + when(member.isAssignedTo(userId)).thenReturn(false); assertThatThrownBy(() -> memberUpdater.assignMember(settlementId, memberId, userId)) .isInstanceOf(MemberAlreadyAssignedException.class); + + verify(memberRepository, never()).findBySettlementIdAndUserId(anyLong(), anyLong()); + verify(memberRepository, never()).save(any()); } @DisplayName("로그인 사용자가 본인이 선택한 참여자를 해제할 수 있다.") diff --git a/src/test/java/com/dnd/moddo/domain/memberExpense/service/implementation/MemberExpenseReaderTest.java b/src/test/java/com/dnd/moddo/domain/memberExpense/service/implementation/MemberExpenseReaderTest.java index bc197dea..c4035a83 100644 --- a/src/test/java/com/dnd/moddo/domain/memberExpense/service/implementation/MemberExpenseReaderTest.java +++ b/src/test/java/com/dnd/moddo/domain/memberExpense/service/implementation/MemberExpenseReaderTest.java @@ -135,4 +135,55 @@ void findAllByExpenseIds_Success() { verify(memberExpenseRepository, times(1)).findAllByExpenseIds(eq(expenseIds)); } + + @DisplayName("참여자 ID로 참여자별 지출내역을 조회할 수 있다.") + @Test + void findAllByMemberIdSuccess() { + Long memberId = 1L; + List expected = List.of( + new MemberExpense(1L, mockMember, 1000L), + new MemberExpense(2L, mockMember, 2000L) + ); + + when(memberExpenseRepository.findByMemberId(memberId)).thenReturn(expected); + + List result = memberExpenseReader.findAllByMemberId(memberId); + + assertThat(result).isEqualTo(expected); + verify(memberExpenseRepository).findByMemberId(memberId); + } + + @DisplayName("참여자 ID 목록이 비어있으면 빈 리스트를 반환한다.") + @Test + void findAllByMemberIdsWhenEmpty() { + List result = memberExpenseReader.findAllByMemberIds(List.of()); + + assertThat(result).isEmpty(); + verify(memberExpenseRepository, never()).findAllByMemberIds(any()); + } + + @DisplayName("참여자 ID 목록이 null이면 빈 리스트를 반환한다.") + @Test + void findAllByMemberIdsWhenNull() { + List result = memberExpenseReader.findAllByMemberIds(null); + + assertThat(result).isEmpty(); + verify(memberExpenseRepository, never()).findAllByMemberIds(any()); + } + + @DisplayName("참여자 ID 목록으로 참여자별 지출내역을 조회할 수 있다.") + @Test + void findAllByMemberIdsSuccess() { + List memberIds = List.of(1L, 2L); + List expected = List.of( + new MemberExpense(1L, mockMember, 1000L) + ); + + when(memberExpenseRepository.findAllByMemberIds(memberIds)).thenReturn(expected); + + List result = memberExpenseReader.findAllByMemberIds(memberIds); + + assertThat(result).isEqualTo(expected); + verify(memberExpenseRepository).findAllByMemberIds(memberIds); + } } diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java index 7ccdd2e7..df39ee60 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java @@ -42,7 +42,7 @@ void getPaymentRequests() throws Exception { 1L, 2L, "김반숙", - "https://moddo-s3.s3.amazonaws.com/profile/1.png", + "profile-1.png", 12000L ) ) @@ -55,6 +55,7 @@ void getPaymentRequests() throws Exception { .andExpect(jsonPath("$.paymentRequests").isArray()) .andExpect(jsonPath("$.paymentRequests[0].paymentRequestId").value(1L)) .andExpect(jsonPath("$.paymentRequests[0].name").value("김반숙")) + .andExpect(jsonPath("$.paymentRequests[0].profileUrl").value("profile-1.png")) .andExpect(jsonPath("$.paymentRequests[0].totalAmount").value(12000L)) .andDo(restDocs.document( responseFields( diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/entity/PaymentRequestTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/entity/PaymentRequestTest.java new file mode 100644 index 00000000..861ba137 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/entity/PaymentRequestTest.java @@ -0,0 +1,129 @@ +package com.dnd.moddo.domain.paymentRequest.entity; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.dnd.moddo.event.domain.member.ExpenseRole; +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; +import com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus; +import com.dnd.moddo.event.domain.settlement.Settlement; +import com.dnd.moddo.global.support.GroupTestFactory; +import com.dnd.moddo.global.support.UserTestFactory; +import com.dnd.moddo.user.domain.User; + +class PaymentRequestTest { + + @Test + @DisplayName("입금 확인 요청을 생성하면 초기 상태는 PENDING이다.") + void createPaymentRequest() { + Settlement settlement = GroupTestFactory.createDefault(); + Member requestMember = createMember(settlement); + User targetUser = UserTestFactory.createWithEmail("target@test.com"); + + setField(settlement, "id", 1L); + setField(requestMember, "id", 2L); + setField(targetUser, "id", 3L); + + PaymentRequest paymentRequest = PaymentRequest.builder() + .settlement(settlement) + .requestMember(requestMember) + .targetUser(targetUser) + .build(); + + assertThat(paymentRequest.getStatus()).isEqualTo(PaymentRequestStatus.PENDING); + assertThat(paymentRequest.getRequestedAt()).isNotNull(); + assertThat(paymentRequest.getProcessedAt()).isNull(); + } + + @Test + @DisplayName("입금 확인 요청을 승인하면 상태와 처리 시각이 변경된다.") + void approve() { + PaymentRequest paymentRequest = createPaymentRequestWithIds(); + + paymentRequest.approve(); + + assertThat(paymentRequest.getStatus()).isEqualTo(PaymentRequestStatus.APPROVED); + assertThat(paymentRequest.getProcessedAt()).isNotNull(); + } + + @Test + @DisplayName("입금 확인 요청을 거절하면 상태와 처리 시각이 변경된다.") + void reject() { + PaymentRequest paymentRequest = createPaymentRequestWithIds(); + + paymentRequest.reject(); + + assertThat(paymentRequest.getStatus()).isEqualTo(PaymentRequestStatus.REJECTED); + assertThat(paymentRequest.getProcessedAt()).isNotNull(); + } + + @Test + @DisplayName("이미 승인된 입금 확인 요청은 다시 승인할 수 없다.") + void approveFailWhenAlreadyProcessed() { + PaymentRequest paymentRequest = createPaymentRequestWithIds(); + paymentRequest.approve(); + + assertThatThrownBy(paymentRequest::approve) + .isInstanceOf(IllegalStateException.class) + .hasMessage("이미 처리된 입금 요청입니다."); + } + + @Test + @DisplayName("이미 거절된 입금 확인 요청은 다시 거절할 수 없다.") + void rejectFailWhenAlreadyProcessed() { + PaymentRequest paymentRequest = createPaymentRequestWithIds(); + paymentRequest.reject(); + + assertThatThrownBy(paymentRequest::reject) + .isInstanceOf(IllegalStateException.class) + .hasMessage("이미 처리된 입금 요청입니다."); + } + + @Test + @DisplayName("연관 엔티티의 식별자를 helper 메서드로 조회할 수 있다.") + void helperGetters() { + PaymentRequest paymentRequest = createPaymentRequestWithIds(); + + assertThat(paymentRequest.getSettlementId()).isEqualTo(1L); + assertThat(paymentRequest.getRequestMemberId()).isEqualTo(2L); + assertThat(paymentRequest.getTargetUserId()).isEqualTo(3L); + } + + private PaymentRequest createPaymentRequestWithIds() { + Settlement settlement = GroupTestFactory.createDefault(); + Member requestMember = createMember(settlement); + User targetUser = UserTestFactory.createWithEmail("target@test.com"); + + setField(settlement, "id", 1L); + setField(requestMember, "id", 2L); + setField(targetUser, "id", 3L); + + return PaymentRequest.builder() + .settlement(settlement) + .requestMember(requestMember) + .targetUser(targetUser) + .build(); + } + + private Member createMember(Settlement settlement) { + return Member.builder() + .name("김반숙") + .profileId(1) + .settlement(settlement) + .role(ExpenseRole.PARTICIPANT) + .build(); + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java index 2f2dd26d..15745d65 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java @@ -33,7 +33,7 @@ class CommandPaymentRequestTest { @DisplayName("입금 확인 요청을 생성할 수 있다.") void createPaymentRequest() { PaymentRequest paymentRequest = mock(PaymentRequest.class); - stubPaymentRequest(paymentRequest); + stubPaymentRequest(paymentRequest, PaymentRequestStatus.PENDING); when(paymentRequestCreator.createPaymentRequest(1L, 2L)).thenReturn(paymentRequest); PaymentRequestResponse response = commandPaymentRequest.createPaymentRequest(1L, 2L); @@ -46,33 +46,33 @@ void createPaymentRequest() { @DisplayName("입금 확인 요청을 승인할 수 있다.") void approvePaymentRequest() { PaymentRequest paymentRequest = mock(PaymentRequest.class); - stubPaymentRequest(paymentRequest); + stubPaymentRequest(paymentRequest, PaymentRequestStatus.APPROVED); when(paymentRequestUpdater.approvePaymentRequest(1L, 2L)).thenReturn(paymentRequest); PaymentRequestResponse response = commandPaymentRequest.approvePaymentRequest(1L, 2L); assertThat(response.id()).isEqualTo(1L); - assertThat(response.status()).isEqualTo(PaymentRequestStatus.PENDING); + assertThat(response.status()).isEqualTo(PaymentRequestStatus.APPROVED); } @Test @DisplayName("입금 확인 요청을 거절할 수 있다.") void rejectPaymentRequest() { PaymentRequest paymentRequest = mock(PaymentRequest.class); - stubPaymentRequest(paymentRequest); + stubPaymentRequest(paymentRequest, PaymentRequestStatus.REJECTED); when(paymentRequestUpdater.rejectPaymentRequest(1L, 2L)).thenReturn(paymentRequest); PaymentRequestResponse response = commandPaymentRequest.rejectPaymentRequest(1L, 2L); assertThat(response.id()).isEqualTo(1L); - assertThat(response.status()).isEqualTo(PaymentRequestStatus.PENDING); + assertThat(response.status()).isEqualTo(PaymentRequestStatus.REJECTED); } - private void stubPaymentRequest(PaymentRequest paymentRequest) { + private void stubPaymentRequest(PaymentRequest paymentRequest, PaymentRequestStatus status) { when(paymentRequest.getId()).thenReturn(1L); when(paymentRequest.getSettlementId()).thenReturn(2L); when(paymentRequest.getRequestMemberId()).thenReturn(3L); when(paymentRequest.getTargetUserId()).thenReturn(4L); - when(paymentRequest.getStatus()).thenReturn(PaymentRequestStatus.PENDING); + when(paymentRequest.getStatus()).thenReturn(status); } } diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java index 2255fdf0..99b81613 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java @@ -36,7 +36,7 @@ void findByTargetUserId() { 1L, 2L, "김반숙", - "https://moddo-s3.s3.amazonaws.com/profile/1.png", + "profile-1.png", 10000L )) ); diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java index e4cad15f..bdaec97e 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java @@ -91,12 +91,12 @@ void findByTargetUserId() { assertThat(first.paymentRequestId()).isEqualTo(2L); assertThat(first.name()).isEqualTo("김모또"); - assertThat(first.profileUrl()).isEqualTo("https://moddo-s3.s3.amazonaws.com/profile/2.png"); + assertThat(first.profileUrl()).isEqualTo(member2.getProfileUrl()); assertThat(first.totalAmount()).isEqualTo(7000L); assertThat(second.paymentRequestId()).isEqualTo(1L); assertThat(second.name()).isEqualTo("김반숙"); - assertThat(second.profileUrl()).isEqualTo("https://moddo-s3.s3.amazonaws.com/profile/1.png"); + assertThat(second.profileUrl()).isEqualTo(member1.getProfileUrl()); assertThat(second.totalAmount()).isEqualTo(5000L); } From 141a8c55b39b2ed2a6722ea2ce1b4b0c76804646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 01:19:13 +0900 Subject: [PATCH 5/7] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/Member/service/implementation/MemberDeleterTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberDeleterTest.java b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberDeleterTest.java index fea965a8..ba86f859 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberDeleterTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberDeleterTest.java @@ -71,7 +71,7 @@ void delete_ThrowException_WithInvalidExpenseId() { //when & then assertThatThrownBy(() -> { memberDeleter.delete(appointmentMember); - }).hasMessage("해당 참여자를 찾을 수 없습니다. (AppointmentMember ID: " + appointmentMember + ")"); + }).hasMessage("해당 참여자를 찾을 수 없습니다."); } @DisplayName("유효한 참여자 id로 삭제를 요청하면 성공적으로 삭제된다.") From fd461582df19007297cdb507741e3a34399d4a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 01:21:59 +0900 Subject: [PATCH 6/7] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/exception/MemberNotFoundException.java | 2 +- .../Member/service/implementation/MemberReaderTest.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberNotFoundException.java b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberNotFoundException.java index b2c9c366..f4bae27f 100644 --- a/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberNotFoundException.java +++ b/src/main/java/com/dnd/moddo/event/domain/member/exception/MemberNotFoundException.java @@ -6,6 +6,6 @@ public class MemberNotFoundException extends ModdoException { public MemberNotFoundException(Long memberId) { - super(HttpStatus.NOT_FOUND, "해당 참여자를 찾을 수 없습니다. (MEMBER ID: " + memberId + ")"); + super(HttpStatus.NOT_FOUND, "해당 참여자를 찾을 수 없습니다."); } } diff --git a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java index 2a3c6b7e..d500eba2 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java @@ -4,6 +4,7 @@ import static org.mockito.Mockito.*; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -23,8 +24,6 @@ import com.dnd.moddo.event.infrastructure.MemberRepository; import com.dnd.moddo.global.support.GroupTestFactory; -import java.util.Optional; - @ExtendWith(MockitoExtension.class) public class MemberReaderTest { @Mock @@ -133,7 +132,7 @@ void findByGroupMemberIdFail() { //when & then assertThatThrownBy(() -> { memberReader.findByAppointmentMemberId(appointmentMember); - }).hasMessage("해당 참여자를 찾을 수 없습니다. (MEMBER ID: " + appointmentMember + ")"); + }).hasMessage("해당 참여자를 찾을 수 없습니다."); } @DisplayName("정산 ID로 참여자 ID 목록을 조회할 수 있다.") @@ -162,7 +161,8 @@ void findBySettlementIdAndUserIdSuccess() { .isPaid(false) .build(); - when(memberRepository.findBySettlementIdAndUserId(settlementId, userId)).thenReturn(Optional.of(expectedMember)); + when(memberRepository.findBySettlementIdAndUserId(settlementId, userId)).thenReturn( + Optional.of(expectedMember)); Member result = memberReader.findBySettlementIdAndUserId(settlementId, userId); From 608526fe4feea631b2c226f4b506841cc3e4105a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 01:38:16 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=EC=B0=B8=EC=97=AC=EC=9E=90=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?-=20=EC=B0=B8=EC=97=AC=EC=9E=90=EA=B0=80=20=EC=A0=95=EC=82=B0?= =?UTF-8?q?=EB=8B=B4=EB=8B=B9=EC=9E=90=EC=9D=BC=EB=95=8C=EB=8A=94=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=95=88=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/application/impl/MemberUpdater.java | 7 ++++- .../implementation/MemberUpdaterTest.java | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java b/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java index 48008c39..9f03f1db 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/MemberUpdater.java @@ -91,7 +91,12 @@ public Member assignMember(Long settlementId, Long memberId, Long userId) { } memberRepository.findBySettlementIdAndUserId(settlementId, userId) - .ifPresent(member -> member.unassignUser(userId)); + .ifPresent(member -> { + if (member.isManager()) { + throw new MemberSelectionNotAllowedException(member.getId()); + } + member.unassignUser(userId); + }); User user = userRepository.getById(userId); targetMember.assignUser(user); diff --git a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberUpdaterTest.java b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberUpdaterTest.java index 194a130e..de1e0ef3 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberUpdaterTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberUpdaterTest.java @@ -275,6 +275,7 @@ void assignMemberReplaceExistingSelection() { when(member.isInSettlement(settlementId)).thenReturn(true); when(member.isManager()).thenReturn(false); when(member.isAssigned()).thenReturn(false); + when(assignedMember.isManager()).thenReturn(false); when(memberRepository.findBySettlementIdAndUserId(settlementId, userId)) .thenReturn(Optional.of(assignedMember)); when(userRepository.getById(userId)).thenReturn(user); @@ -289,6 +290,32 @@ void assignMemberReplaceExistingSelection() { verify(memberRepository).save(member); } + @DisplayName("기존 선택이 총무라면 자동 해제하지 않고 예외가 발생한다.") + @Test + void assignMemberFailWhenExistingSelectionIsManager() { + Long settlementId = 1L; + Long memberId = 2L; + Long userId = 3L; + Member member = mock(Member.class); + Member assignedMember = mock(Member.class); + + when(memberRepository.getById(memberId)).thenReturn(member); + when(member.isInSettlement(settlementId)).thenReturn(true); + when(member.isManager()).thenReturn(false); + when(member.isAssigned()).thenReturn(false); + when(assignedMember.isManager()).thenReturn(true); + when(assignedMember.getId()).thenReturn(1L); + when(memberRepository.findBySettlementIdAndUserId(settlementId, userId)) + .thenReturn(Optional.of(assignedMember)); + + assertThatThrownBy(() -> memberUpdater.assignMember(settlementId, memberId, userId)) + .isInstanceOf(MemberSelectionNotAllowedException.class); + + verify(assignedMember, never()).unassignUser(anyLong()); + verify(member, never()).assignUser(any()); + verify(memberRepository, never()).save(any()); + } + @DisplayName("이미 본인이 선택한 참여자를 다시 선택하면 그대로 반환한다.") @Test void assignMemberWhenAlreadyAssignedToMe() {