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/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..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 @@ -20,11 +20,22 @@ 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 findAllByMemberId(Long memberId) { + return memberExpenseRepository.findByMemberId(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/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..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 @@ -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; @@ -61,10 +60,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; @@ -75,20 +79,29 @@ public Member updatePaymentStatus(Long appointmentMemberId, PaymentStatusUpdateR @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 -> { + if (member.isManager()) { + throw new MemberSelectionNotAllowedException(member.getId()); + } + 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/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/PaymentRequestReader.java b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java new file mode 100644 index 00000000..9deb063e --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java @@ -0,0 +1,64 @@ +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(me -> me.getAmount() != null ? me.getAmount() : 0L) + )); + + List responses = paymentRequests.stream() + .map(paymentRequest -> { + Member member = paymentRequest.getRequestMember(); + Long memberId = member.getId(); + + return new PaymentRequestItemResponse( + paymentRequest.getRequestedAt(), + paymentRequest.getId(), + memberId, + member.getName(), + member.getProfileUrl(), + 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/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/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/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..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 @@ -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, "해당 참여자를 찾을 수 없습니다."); } } 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/domain/paymentRequest/PaymentRequest.java b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequest.java new file mode 100644 index 00000000..3b75d095 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/paymentRequest/PaymentRequest.java @@ -0,0 +1,92 @@ +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", nullable = false) + private Settlement settlement; + + @ManyToOne + @JoinColumn(name = "request_member_id", nullable = false) + private Member requestMember; + + @ManyToOne + @JoinColumn(name = "target_user_id", nullable = false) + 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; + } + + 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(); + } + + 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..36d4bc7d --- /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.FORBIDDEN, "정산 담당자는 입금 확인 요청을 보낼 수 없습니다."); + } +} 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..2764aeb9 --- /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, + "해당 입금 확인 요청을 처리할 권한이 없습니다." + ); + } +} 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..6e1eaf45 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,10 @@ 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/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..4b830dec --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/infrastructure/PaymentRequestRepository.java @@ -0,0 +1,35 @@ +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)); + } + + @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 new file mode 100644 index 00000000..c18b4401 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/presentation/PaymentRequestController.java @@ -0,0 +1,64 @@ +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; +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.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; + +@RestController +@RequiredArgsConstructor +@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, + @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/PaymentRequestItemResponse.java b/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestItemResponse.java new file mode 100644 index 00000000..07b5b182 --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/presentation/response/PaymentRequestItemResponse.java @@ -0,0 +1,13 @@ +package com.dnd.moddo.event.presentation.response; + +import java.time.LocalDateTime; + +public record PaymentRequestItemResponse( + LocalDateTime requestedAt, + Long paymentRequestId, + Long memberId, + String name, + String profileUrl, + Long totalAmount +) { +} 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/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/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/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로 삭제를 요청하면 성공적으로 삭제된다.") 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..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; @@ -131,7 +132,7 @@ void findByGroupMemberIdFail() { //when & then assertThatThrownBy(() -> { memberReader.findByAppointmentMemberId(appointmentMember); - }).hasMessage("해당 참여자를 찾을 수 없습니다. (AppointmentMember ID: " + appointmentMember + ")"); + }).hasMessage("해당 참여자를 찾을 수 없습니다."); } @DisplayName("정산 ID로 참여자 ID 목록을 조회할 수 있다.") @@ -148,4 +149,37 @@ 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..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 @@ -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,64 @@ 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(member.isAssigned()).thenReturn(false); + when(assignedMember.isManager()).thenReturn(false); + when(memberRepository.findBySettlementIdAndUserId(settlementId, userId)) + .thenReturn(Optional.of(assignedMember)); + when(userRepository.getById(userId)).thenReturn(user); + when(memberRepository.save(member)).thenReturn(member); + + 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("기존 선택이 총무라면 자동 해제하지 않고 예외가 발생한다.") + @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(memberRepository.existsBySettlementIdAndUserId(settlementId, userId)).thenReturn(true); + 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(UserAlreadyAssignedException.class); + .isInstanceOf(MemberSelectionNotAllowedException.class); + + verify(assignedMember, never()).unassignUser(anyLong()); + verify(member, never()).assignUser(any()); + verify(memberRepository, never()).save(any()); } - @DisplayName("이미 선택된 참여자를 다시 선택하면 예외가 발생한다.") + @DisplayName("이미 본인이 선택한 참여자를 다시 선택하면 그대로 반환한다.") @Test - void assignMemberFailWhenMemberAlreadyAssigned() { + void assignMemberWhenAlreadyAssignedToMe() { Long settlementId = 1L; Long memberId = 2L; Long userId = 3L; @@ -289,10 +328,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 new file mode 100644 index 00000000..df39ee60 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/controller/PaymentRequestControllerTest.java @@ -0,0 +1,168 @@ +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.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 { + + @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 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, + "김반숙", + "profile-1.png", + 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].profileUrl").value("profile-1.png")) + .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[].profileUrl").type(JsonFieldType.STRING).description("요청 참여자 프로필 URL"), + 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, PaymentRequestStatus.PENDING + ); + + 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.STRING).description("요청 상태") + ) + )); + } + + @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/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 new file mode 100644 index 00000000..15745d65 --- /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, PaymentRequestStatus.PENDING); + 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, 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.APPROVED); + } + + @Test + @DisplayName("입금 확인 요청을 거절할 수 있다.") + void rejectPaymentRequest() { + PaymentRequest paymentRequest = mock(PaymentRequest.class); + 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.REJECTED); + } + + 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(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 new file mode 100644 index 00000000..99b81613 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java @@ -0,0 +1,50 @@ +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, + "김반숙", + "profile-1.png", + 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/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/PaymentRequestReaderTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java new file mode 100644 index 00000000..bdaec97e --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java @@ -0,0 +1,112 @@ +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.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; +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(paymentRequest1, "status", PaymentRequestStatus.PENDING); + setField(paymentRequest2, "status", PaymentRequestStatus.PENDING); + 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.profileUrl()).isEqualTo(member2.getProfileUrl()); + assertThat(first.totalAmount()).isEqualTo(7000L); + + assertThat(second.paymentRequestId()).isEqualTo(1L); + assertThat(second.name()).isEqualTo("김반숙"); + assertThat(second.profileUrl()).isEqualTo(member1.getProfileUrl()); + 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/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..0366172b 100644 --- a/src/test/java/com/dnd/moddo/global/util/ControllerTest.java +++ b/src/test/java/com/dnd/moddo/global/util/ControllerTest.java @@ -18,14 +18,17 @@ 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; 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; 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 +46,7 @@ ExpenseController.class, SettlementController.class, MemberController.class, + PaymentRequestController.class, ImageController.class, MemberExpenseController.class, com.dnd.moddo.user.presentation.UserController.class, @@ -90,6 +94,12 @@ public abstract class ControllerTest { @MockBean protected CommandMemberService commandMemberService; + @MockBean + protected CommandPaymentRequest commandPaymentRequest; + + @MockBean + protected QueryPaymentRequestService queryPaymentRequestService; + @MockBean protected CommandImageService commandImageService;