diff --git a/src/main/java/com/donggi/sendzy/account/application/AccountLockingService.java b/src/main/java/com/donggi/sendzy/account/application/AccountLockingService.java new file mode 100644 index 0000000..ed57b75 --- /dev/null +++ b/src/main/java/com/donggi/sendzy/account/application/AccountLockingService.java @@ -0,0 +1,54 @@ +package com.donggi.sendzy.account.application; + +import com.donggi.sendzy.account.domain.Account; +import com.donggi.sendzy.account.domain.AccountRepository; +import com.donggi.sendzy.account.domain.LockedAccounts; +import com.donggi.sendzy.account.exception.AccountNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Stream; + +@RequiredArgsConstructor +@Service +public class AccountLockingService { + + private final AccountRepository accountRepository; + + /** + * 두 개의 계좌를 계좌 ID 기준으로 오름차순 정렬하여 락을 획득합니다. + * 데드락을 방지하기 위해 항상 일정한 순서로 락을 획득합니다. + * @param senderId 송금 회원 ID + * @param receiverId 수신 회원 ID + * @return 락을 획득한 계좌 목록 + */ + @Transactional + public LockedAccounts getAccountsWithLockOrdered(final long senderId, final long receiverId) { + final List sortedIds = getSortedIds(senderId, receiverId); + final List lockedAccounts = sortedIds.stream() + .map(accountId -> accountRepository.findByMemberIdForUpdate(accountId) + .orElseThrow(() -> new AccountNotFoundException(accountId))) + .toList(); + + return LockedAccounts.of(lockedAccounts, senderId, receiverId); + } + + /** + * 회원 ID로 계좌를 조회하고 해당 계좌의 락을 획득합니다. + * @param senderId 송신자 ID + * @return 조회된 계좌 + */ + @Transactional + public Account getByMemberIdForUpdate(final long senderId) { + return accountRepository.findByMemberIdForUpdate(senderId) + .orElseThrow(() -> new AccountNotFoundException(senderId)); + } + + private List getSortedIds(final long senderId, final long receiverId) { + return Stream.of(senderId, receiverId) + .sorted() + .toList(); + } +} diff --git a/src/main/java/com/donggi/sendzy/account/domain/Account.java b/src/main/java/com/donggi/sendzy/account/domain/Account.java index d7fe9ae..49724c1 100644 --- a/src/main/java/com/donggi/sendzy/account/domain/Account.java +++ b/src/main/java/com/donggi/sendzy/account/domain/Account.java @@ -22,11 +22,20 @@ public Account(final Long memberId) { this.pendingAmount = 0L; } - public void withdraw(final Long amount) { + public void reserveWithdraw(final Long amount) { validateWithdraw(amount); this.pendingAmount += amount; } + public void commitWithdraw(final long amount) { + this.balance -= amount; + this.pendingAmount -= amount; + } + + public void cancelWithdraw(final long amount) { + this.pendingAmount -= amount; + } + public void deposit(final Long amount) { final var fieldName = "amount"; Validator.notNull(amount, fieldName); diff --git a/src/main/java/com/donggi/sendzy/account/domain/AccountRepository.java b/src/main/java/com/donggi/sendzy/account/domain/AccountRepository.java index c901d92..350f712 100644 --- a/src/main/java/com/donggi/sendzy/account/domain/AccountRepository.java +++ b/src/main/java/com/donggi/sendzy/account/domain/AccountRepository.java @@ -16,26 +16,18 @@ public interface AccountRepository { * @param memberId 회원 ID * @return 조회된 계좌 */ - Optional findByMemberId(final Long memberId); + Optional findByMemberId(final long memberId); /** * 회원 ID로 계좌를 조회하고, 조회된 계좌에 배타적 잠금(Exclusive Lock)을 겁니다. * @param memberId 회원 ID * @return 잠금이 설정된 계좌(Optional) */ - Optional findByIdForUpdate(final Long memberId); + Optional findByMemberIdForUpdate(final long memberId); /** - * 계좌의 대기 중인 금액을 업데이트합니다. - * @param id 계좌 ID - * @param pendingAmount 대기 중인 금액 + * 계좌를 업데이트합니다. + * @param account 업데이트할 계좌 */ - void updatePendingAmount(final Long id, final Long pendingAmount); - - /** - * 계좌의 잔액을 업데이트합니다. - * @param id 계좌 ID - * @param balance 잔액 - */ - void updateBalance(final Long id, final Long balance); + void update(final Account account); } diff --git a/src/main/java/com/donggi/sendzy/account/domain/AccountService.java b/src/main/java/com/donggi/sendzy/account/domain/AccountService.java index 173e5c7..fb40342 100644 --- a/src/main/java/com/donggi/sendzy/account/domain/AccountService.java +++ b/src/main/java/com/donggi/sendzy/account/domain/AccountService.java @@ -19,19 +19,26 @@ public Account getByMemberId(final long memberId) { @Transactional public void withdraw(final Account account, final long amount) { - account.withdraw(amount); - accountRepository.updatePendingAmount(account.getId(), account.getPendingAmount()); + account.reserveWithdraw(amount); + accountRepository.update(account); } @Transactional public void deposit(final Account account, final long amount) { account.deposit(amount); - accountRepository.updateBalance(account.getId(), account.getBalance()); + accountRepository.update(account); } - @Transactional(readOnly = true) - public Account getByIdForUpdate(final long memberId) { - return accountRepository.findByIdForUpdate(memberId) - .orElseThrow(() -> new AccountNotFoundException(memberId)); + @Transactional + public void transfer(final Account sender, final Account receiver, final long amount) { + sender.commitWithdraw(amount); + receiver.deposit(amount); + accountRepository.update(sender); + accountRepository.update(receiver); + } + + @Transactional + public void update(final Account account) { + accountRepository.update(account); } } diff --git a/src/main/java/com/donggi/sendzy/account/domain/LockedAccounts.java b/src/main/java/com/donggi/sendzy/account/domain/LockedAccounts.java new file mode 100644 index 0000000..60d7f26 --- /dev/null +++ b/src/main/java/com/donggi/sendzy/account/domain/LockedAccounts.java @@ -0,0 +1,23 @@ +package com.donggi.sendzy.account.domain; + +import java.util.List; + +/** + * 락이 걸린 송금자 계좌와 수신자 계좌를 묶어 표현하는 도메인 객체 + * + * @param senderAccount 송금자 계좌 + * @param receiverAccount 수신자 계좌 + */ +public record LockedAccounts( + Account senderAccount, + Account receiverAccount +) { + public static LockedAccounts of(final List lockedAccounts, final long senderId, final long receiverId) { + final var first = lockedAccounts.get(0); + final var second = lockedAccounts.get(1); + + final var sender = first.getMemberId() == senderId ? first : second; + final var receiver = first.getMemberId() == receiverId ? first : second; + return new LockedAccounts(sender, receiver); + } +} diff --git a/src/main/java/com/donggi/sendzy/account/infrastructure/AccountMapper.java b/src/main/java/com/donggi/sendzy/account/infrastructure/AccountMapper.java index b345a3c..2e339ee 100644 --- a/src/main/java/com/donggi/sendzy/account/infrastructure/AccountMapper.java +++ b/src/main/java/com/donggi/sendzy/account/infrastructure/AccountMapper.java @@ -4,7 +4,6 @@ import com.donggi.sendzy.account.domain.AccountRepository; import com.donggi.sendzy.account.domain.TestAccountRepository; import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; import java.util.Optional; @@ -12,11 +11,11 @@ public interface AccountMapper extends AccountRepository, TestAccountRepository { Long create(final Account account); - Optional findByMemberId(final Long memberId); - void deleteAll(); - void updatePendingAmount(@Param("id") final Long id, @Param("pendingAmount") final Long pendingAmount); + void update(final Account account); + + Optional findByMemberId(final long memberId); - void updateBalance(@Param("id") final Long id, @Param("balance") final Long balance); + Optional findByMemberIdForUpdate(final long memberId); } diff --git a/src/main/java/com/donggi/sendzy/common/exception/ClientErrorControllerAdvice.java b/src/main/java/com/donggi/sendzy/common/exception/ClientErrorControllerAdvice.java index 305c8f5..32d2231 100644 --- a/src/main/java/com/donggi/sendzy/common/exception/ClientErrorControllerAdvice.java +++ b/src/main/java/com/donggi/sendzy/common/exception/ClientErrorControllerAdvice.java @@ -3,7 +3,7 @@ import com.donggi.sendzy.account.exception.InvalidWithdrawalException; import com.donggi.sendzy.member.exception.EmailDuplicatedException; import com.donggi.sendzy.member.exception.InvalidPasswordException; -import com.donggi.sendzy.member.exception.MemberNotFoundException; +import com.donggi.sendzy.remittance.exception.InvalidRemittanceRequestStatusException; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.security.access.AccessDeniedException; @@ -85,4 +85,12 @@ public ProblemDetail handleInvalidWithdrawalException(final InvalidWithdrawalExc public ProblemDetail handleBadRequestException(final BadRequestException e) { return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage()); } + + /** + * 송금 요청이 수락 또는 거절 가능한 상태가 아닌 경우 + */ + @ExceptionHandler(InvalidRemittanceRequestStatusException.class) + public ProblemDetail handleInvalidRemittanceRequestStatusException(final InvalidRemittanceRequestStatusException e) { + return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage()); + } } diff --git a/src/main/java/com/donggi/sendzy/remittance/application/RemittanceRequestApplicationService.java b/src/main/java/com/donggi/sendzy/remittance/application/RemittanceRequestApplicationService.java index f70af24..033ce54 100644 --- a/src/main/java/com/donggi/sendzy/remittance/application/RemittanceRequestApplicationService.java +++ b/src/main/java/com/donggi/sendzy/remittance/application/RemittanceRequestApplicationService.java @@ -1,7 +1,9 @@ package com.donggi.sendzy.remittance.application; +import com.donggi.sendzy.account.application.AccountLockingService; import com.donggi.sendzy.account.domain.Account; import com.donggi.sendzy.account.domain.AccountService; +import com.donggi.sendzy.account.domain.LockedAccounts; import com.donggi.sendzy.common.exception.BadRequestException; import com.donggi.sendzy.member.domain.Member; import com.donggi.sendzy.member.domain.MemberService; @@ -16,14 +18,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.stream.Stream; - @Service @RequiredArgsConstructor public class RemittanceRequestApplicationService { private final AccountService accountService; + private final AccountLockingService accountLockingService; private final RemittanceHistoryService remittanceHistoryService; private final RemittanceRequestService remittanceRequestService; private final RemittanceStatusHistoryService remittanceStatusHistoryService; @@ -40,12 +40,9 @@ public long createRemittanceRequest(final Long senderId, final Long receiverId, validateSenderAndReceiver(senderId, receiverId); // 계좌 ID를 오름차순으로 정렬하여 일관된 순서로 Lock 확보 - List sortedIds = getSortedIds(senderId, receiverId); - final Account firstAccount = accountService.getByIdForUpdate(sortedIds.get(0)); - final Account secondAccount = accountService.getByIdForUpdate(sortedIds.get(1)); - - final Account senderAccount = senderId.equals(firstAccount.getMemberId()) ? firstAccount : secondAccount; - final Account receiverAccount = receiverId.equals(firstAccount.getMemberId()) ? firstAccount : secondAccount; + final LockedAccounts lockedAccounts = accountLockingService.getAccountsWithLockOrdered(senderId, receiverId); + final Account senderAccount = lockedAccounts.senderAccount(); + final Account receiverAccount = lockedAccounts.receiverAccount(); final var sender = memberService.findById(senderAccount.getMemberId()); final var receiver = memberService.findById(receiverAccount.getMemberId()); @@ -112,10 +109,4 @@ private void validateSenderAndReceiver(final Long senderId, final Long receiverI throw new BadRequestException("송금자와 수신자가 동일합니다."); } } - - private List getSortedIds(final Long senderId, final Long receiverId) { - return Stream.of(senderId, receiverId) - .sorted() - .toList(); - } } diff --git a/src/main/java/com/donggi/sendzy/remittance/application/RemittanceRequestProcessor.java b/src/main/java/com/donggi/sendzy/remittance/application/RemittanceRequestProcessor.java new file mode 100644 index 0000000..7b2fbcd --- /dev/null +++ b/src/main/java/com/donggi/sendzy/remittance/application/RemittanceRequestProcessor.java @@ -0,0 +1,88 @@ +package com.donggi.sendzy.remittance.application; + +import com.donggi.sendzy.account.application.AccountLockingService; +import com.donggi.sendzy.account.domain.AccountService; +import com.donggi.sendzy.account.domain.LockedAccounts; +import com.donggi.sendzy.remittance.domain.RemittanceRequest; +import com.donggi.sendzy.remittance.domain.RemittanceRequestStatus; +import com.donggi.sendzy.remittance.domain.RemittanceStatusHistory; +import com.donggi.sendzy.remittance.domain.service.RemittanceRequestService; +import com.donggi.sendzy.remittance.domain.service.RemittanceStatusHistoryService; +import com.donggi.sendzy.remittance.exception.InvalidRemittanceRequestStatusException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class RemittanceRequestProcessor { + + private final RemittanceRequestService remittanceRequestService; + private final AccountLockingService accountLockingService; + private final RemittanceStatusHistoryService remittanceStatusHistoryService; + private final AccountService accountService; + + @Transactional + public void handleAcceptance(final long requestId, final long receiverId) { + // 송금 요청 조회 및 상태 확인 (PENDING 여부) + final var remittanceRequest = remittanceRequestService.getByIdForUpdate(requestId); + validateReceiverAuthorityAndStatus(remittanceRequest, receiverId); + + // 송금자/수신자 계좌 락 + 조회 (ID 오름차순 → 데드락 방지) + final LockedAccounts lockedAccounts = accountLockingService.getAccountsWithLockOrdered(remittanceRequest.getSenderId(), remittanceRequest.getReceiverId()); + final var senderAccount = lockedAccounts.senderAccount(); + final var receiverAccount = lockedAccounts.receiverAccount(); + + // 이체 처리 + accountService.transfer(senderAccount, receiverAccount, remittanceRequest.getAmount()); + + // 송금 요청 상태 변경 → ACCEPTED + remittanceRequestService.accept(remittanceRequest); + + // 상태 변경 히스토리 저장 + recordStatus(remittanceRequest, RemittanceRequestStatus.ACCEPTED); + } + + @Transactional + public void handleRejection(final long requestId, final long receiverId) { + // 송금 요청 조회 (락 획득) + final var remittanceRequest = remittanceRequestService.getByIdForUpdate(requestId); + + // 수신자 권한 확인 + validateReceiverAuthorityAndStatus(remittanceRequest, receiverId); + + // 송금자 계좌 롤백 처리 + final var senderAccount = accountLockingService.getByMemberIdForUpdate(remittanceRequest.getSenderId()); + senderAccount.cancelWithdraw(remittanceRequest.getAmount()); + accountService.update(senderAccount); + + // 송금 요청 상태 변경 → REJECTED + remittanceRequestService.reject(remittanceRequest); + + // 상태 변경 히스토리 저장 + recordStatus(remittanceRequest, RemittanceRequestStatus.REJECTED); + } + + private void validateReceiverAuthorityAndStatus(final RemittanceRequest remittanceRequest, final long receiverId) { + if (!remittanceRequest.getReceiverId().equals(receiverId)) { + throw new AccessDeniedException("해당 송금 요청의 수신자만 처리할 수 있습니다."); + } + + if (!remittanceRequest.isPending()) { + throw new InvalidRemittanceRequestStatusException(remittanceRequest.getStatus()); + } + } + + private void recordStatus(RemittanceRequest request, RemittanceRequestStatus status) { + remittanceStatusHistoryService.recordStatusHistory( + new RemittanceStatusHistory( + request.getId(), + request.getSenderId(), + request.getReceiverId(), + request.getAmount(), + status + ) + ); + } +} diff --git a/src/main/java/com/donggi/sendzy/remittance/controller/RemittanceRequestRestController.java b/src/main/java/com/donggi/sendzy/remittance/controller/RemittanceRequestRestController.java new file mode 100644 index 0000000..745cb2f --- /dev/null +++ b/src/main/java/com/donggi/sendzy/remittance/controller/RemittanceRequestRestController.java @@ -0,0 +1,40 @@ +package com.donggi.sendzy.remittance.controller; + +import com.donggi.sendzy.common.security.CustomUserDetails; +import com.donggi.sendzy.remittance.application.RemittanceRequestProcessor; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/v1/remittance") +public class RemittanceRequestRestController { + + private final RemittanceRequestProcessor remittanceRequestProcessor; + + /** + * 송금 요청 수락 + * @param requestId 송금 요청 ID + * @param userDetails 로그인 정보 + */ + @PostMapping("/{requestId}/accept") + public void accept(@PathVariable("requestId") final long requestId, @AuthenticationPrincipal final CustomUserDetails userDetails) { + final var receiverId = userDetails.getMemberId(); + remittanceRequestProcessor.handleAcceptance(requestId, receiverId); + } + + /** + * 송금 요청 거절 + * @param requestId 송금 요청 ID + * @param userDetails 로그인 정보 + */ + @PostMapping("/{requestId}/reject") + public void reject(@PathVariable("requestId") final long requestId, @AuthenticationPrincipal final CustomUserDetails userDetails) { + final var receiverId = userDetails.getMemberId(); + remittanceRequestProcessor.handleRejection(requestId, receiverId); + } +} diff --git a/src/main/java/com/donggi/sendzy/remittance/domain/RemittanceRequest.java b/src/main/java/com/donggi/sendzy/remittance/domain/RemittanceRequest.java index bb24421..48ddf7b 100644 --- a/src/main/java/com/donggi/sendzy/remittance/domain/RemittanceRequest.java +++ b/src/main/java/com/donggi/sendzy/remittance/domain/RemittanceRequest.java @@ -33,4 +33,16 @@ public RemittanceRequest( this.amount = amount; this.createdAt = LocalDateTime.now(); } + + public boolean isPending() { + return this.status == RemittanceRequestStatus.PENDING; + } + + public void accept() { + status = this.status.accept(); + } + + public void reject() { + status = this.status.reject(); + } } diff --git a/src/main/java/com/donggi/sendzy/remittance/domain/RemittanceRequestStatus.java b/src/main/java/com/donggi/sendzy/remittance/domain/RemittanceRequestStatus.java index 91af012..43095b3 100644 --- a/src/main/java/com/donggi/sendzy/remittance/domain/RemittanceRequestStatus.java +++ b/src/main/java/com/donggi/sendzy/remittance/domain/RemittanceRequestStatus.java @@ -1,8 +1,28 @@ package com.donggi.sendzy.remittance.domain; public enum RemittanceRequestStatus { - PENDING, + PENDING { + @Override + public RemittanceRequestStatus accept() { + return ACCEPTED; + } + + @Override + public RemittanceRequestStatus reject() { + return REJECTED; + } + }, ACCEPTED, + REJECTED, EXPIRED, CANCELLED, + ; + + public RemittanceRequestStatus accept() { + throw new UnsupportedOperationException("현재 송금 상태에서는 수락할 수 없습니다: " + this); + } + + public RemittanceRequestStatus reject() { + throw new UnsupportedOperationException("현재 송금 상태에서는 거절할 수 없습니다: " + this); + } } diff --git a/src/main/java/com/donggi/sendzy/remittance/domain/repository/RemittanceRequestRepository.java b/src/main/java/com/donggi/sendzy/remittance/domain/repository/RemittanceRequestRepository.java index 309eeb1..69304d6 100644 --- a/src/main/java/com/donggi/sendzy/remittance/domain/repository/RemittanceRequestRepository.java +++ b/src/main/java/com/donggi/sendzy/remittance/domain/repository/RemittanceRequestRepository.java @@ -18,5 +18,18 @@ public interface RemittanceRequestRepository { * @param requestId 조회할 송금 요청 ID * @return 조회된 송금 요청 정보 */ - Optional findById(long requestId); + Optional findById(final long requestId); + + /** + * 송금 요청 ID로 송금 요청을 조회하고, 조회된 송금 요청에 배타적 잠금(Exclusive Lock)을 겁니다. + * @param requestId 조회할 송금 요청 ID + * @return 조회된 송금 요청 정보(Optional) + */ + Optional findByIdForUpdate(final long requestId); + + /** + * 송금 요청 정보를 업데이트합니다. + * @param remittanceRequest 업데이트할 송금 요청 정보 + */ + void update(final RemittanceRequest remittanceRequest); } diff --git a/src/main/java/com/donggi/sendzy/remittance/domain/service/RemittanceRequestService.java b/src/main/java/com/donggi/sendzy/remittance/domain/service/RemittanceRequestService.java index de5a034..b28ad32 100644 --- a/src/main/java/com/donggi/sendzy/remittance/domain/service/RemittanceRequestService.java +++ b/src/main/java/com/donggi/sendzy/remittance/domain/service/RemittanceRequestService.java @@ -19,9 +19,27 @@ public Long recordRequestAndGetId(final RemittanceRequest remittanceRequest) { return remittanceRequest.getId(); } + @Transactional + public void accept(final RemittanceRequest remittanceRequest) { + remittanceRequest.accept(); + remittanceRequestRepository.update(remittanceRequest); + } + + @Transactional + public void reject(final RemittanceRequest remittanceRequest) { + remittanceRequest.reject(); + remittanceRequestRepository.update(remittanceRequest); + } + @Transactional(readOnly = true) public RemittanceRequest getById(final long requestId) { return remittanceRequestRepository.findById(requestId) .orElseThrow(RemittanceRequestNotFoundException::new); } + + @Transactional(readOnly = true) + public RemittanceRequest getByIdForUpdate(final long requestId) { + return remittanceRequestRepository.findByIdForUpdate(requestId) + .orElseThrow(RemittanceRequestNotFoundException::new); + } } diff --git a/src/main/java/com/donggi/sendzy/remittance/exception/InvalidRemittanceRequestStatusException.java b/src/main/java/com/donggi/sendzy/remittance/exception/InvalidRemittanceRequestStatusException.java new file mode 100644 index 0000000..2da2e39 --- /dev/null +++ b/src/main/java/com/donggi/sendzy/remittance/exception/InvalidRemittanceRequestStatusException.java @@ -0,0 +1,10 @@ +package com.donggi.sendzy.remittance.exception; + +import com.donggi.sendzy.remittance.domain.RemittanceRequestStatus; + +public class InvalidRemittanceRequestStatusException extends RuntimeException { + + public InvalidRemittanceRequestStatusException(final RemittanceRequestStatus status) { + super("이미 처리된 송금 요청입니다. 현재 상태는 '" + status + "'입니다."); + } +} diff --git a/src/main/java/com/donggi/sendzy/remittance/infrastructure/RemittanceRequestMapper.java b/src/main/java/com/donggi/sendzy/remittance/infrastructure/RemittanceRequestMapper.java index fede040..be44b2f 100644 --- a/src/main/java/com/donggi/sendzy/remittance/infrastructure/RemittanceRequestMapper.java +++ b/src/main/java/com/donggi/sendzy/remittance/infrastructure/RemittanceRequestMapper.java @@ -14,5 +14,7 @@ public interface RemittanceRequestMapper extends RemittanceRequestRepository, Te Optional findById(final long requestId); + Optional findByIdForUpdate(final long requestId); + void deleteAll(); } diff --git a/src/main/resources/mappers/AccountMapper.xml b/src/main/resources/mappers/AccountMapper.xml index e790923..ac5bda8 100644 --- a/src/main/resources/mappers/AccountMapper.xml +++ b/src/main/resources/mappers/AccountMapper.xml @@ -12,7 +12,7 @@ SELECT id, member_id, balance, pending_amount FROM account WHERE member_id = #{memberId} - SELECT id, member_id, balance, pending_amount FROM account WHERE member_id = #{memberId} FOR UPDATE @@ -21,11 +21,9 @@ DELETE FROM account - - UPDATE account SET pending_amount = #{pendingAmount} WHERE id = #{id} - - - - UPDATE account SET balance = #{balance} WHERE id = #{id} + + UPDATE account + SET member_id = #{memberId}, balance = #{balance}, pending_amount = #{pendingAmount} + WHERE id = #{id} diff --git a/src/main/resources/mappers/RemittanceRequestMapper.xml b/src/main/resources/mappers/RemittanceRequestMapper.xml index 7eda74b..32b2700 100644 --- a/src/main/resources/mappers/RemittanceRequestMapper.xml +++ b/src/main/resources/mappers/RemittanceRequestMapper.xml @@ -12,7 +12,17 @@ DELETE FROM remittance_request + + UPDATE remittance_request + SET sender_id = #{senderId}, receiver_id = #{receiverId}, status = #{status}, amount = #{amount}, created_at = #{createdAt} + WHERE id = #{id} + + + + diff --git a/src/test/java/com/donggi/sendzy/account/domain/AccountRepositoryTest.java b/src/test/java/com/donggi/sendzy/account/domain/AccountRepositoryTest.java index 091d8b3..9ffacb2 100644 --- a/src/test/java/com/donggi/sendzy/account/domain/AccountRepositoryTest.java +++ b/src/test/java/com/donggi/sendzy/account/domain/AccountRepositoryTest.java @@ -46,7 +46,7 @@ void setUp() { final var memberId = TestUtils.DEFAULT_MEMBER_ID; // when - final var actual = accountRepository.findByIdForUpdate(memberId).get(); + final var actual = accountRepository.findByMemberIdForUpdate(memberId).get(); // then assertThat(actual.getMemberId()).isEqualTo(memberId); diff --git a/src/test/java/com/donggi/sendzy/account/domain/AccountTest.java b/src/test/java/com/donggi/sendzy/account/domain/AccountTest.java index 67d1003..7ea3488 100644 --- a/src/test/java/com/donggi/sendzy/account/domain/AccountTest.java +++ b/src/test/java/com/donggi/sendzy/account/domain/AccountTest.java @@ -84,7 +84,7 @@ class 출금_금액이 { final var account = new Account(memberId); // when & then - assertThatThrownBy(() -> account.withdraw(null)) + assertThatThrownBy(() -> account.reserveWithdraw(null)) .isInstanceOf(ValidException.class) .hasMessage("amount 은/는 null이 될 수 없습니다."); } @@ -97,7 +97,7 @@ class 출금_금액이 { final var amount = -1L; // when & then - assertThatThrownBy(() -> account.withdraw(amount)) + assertThatThrownBy(() -> account.reserveWithdraw(amount)) .isInstanceOf(InvalidWithdrawalException.class) .hasMessage("송금액은 0원 이상이어야 합니다. 사용자 요청 금액: -1"); } @@ -110,7 +110,7 @@ class 출금_금액이 { final var amount = 0L; // when & then - assertThatThrownBy(() -> account.withdraw(amount)) + assertThatThrownBy(() -> account.reserveWithdraw(amount)) .isInstanceOf(InvalidWithdrawalException.class) .hasMessage("송금액은 0원 이상이어야 합니다. 사용자 요청 금액: 0"); } @@ -123,7 +123,7 @@ class 출금_금액이 { final var amount = 100L; // when & then - assertThatThrownBy(() -> account.withdraw(amount)) + assertThatThrownBy(() -> account.reserveWithdraw(amount)) .isInstanceOf(InvalidWithdrawalException.class) .hasMessage("잔액이 부족합니다."); } @@ -138,7 +138,7 @@ class 출금_금액이 { account.deposit(depositAmount); // when - account.withdraw(withdrawAmount); + account.reserveWithdraw(withdrawAmount); // then assertThat(account.getPendingAmount()).isEqualTo(withdrawAmount); diff --git a/src/test/java/com/donggi/sendzy/remittance/application/RemittanceRequestAcceptanceTest.java b/src/test/java/com/donggi/sendzy/remittance/application/RemittanceRequestAcceptanceTest.java new file mode 100644 index 0000000..234e4cd --- /dev/null +++ b/src/test/java/com/donggi/sendzy/remittance/application/RemittanceRequestAcceptanceTest.java @@ -0,0 +1,156 @@ +package com.donggi.sendzy.remittance.application; + +import com.donggi.sendzy.account.domain.TestAccountRepository; +import com.donggi.sendzy.member.TestUtils; +import com.donggi.sendzy.member.application.SignupService; +import com.donggi.sendzy.member.domain.MemberService; +import com.donggi.sendzy.member.domain.TestMemberRepository; +import com.donggi.sendzy.member.dto.SignupRequest; +import com.donggi.sendzy.remittance.domain.RemittanceRequest; +import com.donggi.sendzy.remittance.domain.RemittanceRequestStatus; +import com.donggi.sendzy.remittance.domain.repository.TestRemittanceRequestRepository; +import com.donggi.sendzy.remittance.domain.service.RemittanceRequestService; +import com.donggi.sendzy.remittance.exception.InvalidRemittanceRequestStatusException; +import com.donggi.sendzy.remittance.exception.RemittanceRequestNotFoundException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.access.AccessDeniedException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SuppressWarnings({"InnerClassMayBeStatic", "NonAsciiCharacters"}) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class RemittanceRequestAcceptanceTest { + + @Autowired + private TestMemberRepository memberRepository; + + @Autowired + private TestAccountRepository accountRepository; + + @Autowired + private TestRemittanceRequestRepository remittanceRequestRepository; + + @Autowired + private SignupService signupService; + + @Autowired + private RemittanceRequestService remittanceRequestService; + + @Autowired + private MemberService memberService; + + @Autowired + private RemittanceRequestProcessor remittanceRequestProcessor; + + private Long requestId; + private Long receiverId; + + @BeforeEach + void setUp() { + remittanceRequestRepository.deleteAll(); + accountRepository.deleteAll(); + memberRepository.deleteAll(); + + final var senderEmail = "sender@sendzy.com"; + final var receiverEmail = "receiver@sendzy.com"; + signupService.signup(new SignupRequest(senderEmail, TestUtils.DEFAULT_RAW_PASSWORD)); + signupService.signup(new SignupRequest(receiverEmail, TestUtils.DEFAULT_RAW_PASSWORD)); + + final var sender = memberService.findByEmail(senderEmail).get(); + final var receiver = memberService.findByEmail(receiverEmail).get(); + + receiverId = receiver.getId(); + + requestId = remittanceRequestService.recordRequestAndGetId( + new RemittanceRequest( + sender.getId(), + receiverId, + RemittanceRequestStatus.PENDING, + 1000L + ) + ); + } + + @AfterEach + void tearDown() { + remittanceRequestRepository.deleteAll(); + accountRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @Nested + class 송금_요청_수락_시 { + @Nested + class 송금_요청이_존재하지_않을_경우 { + @Test + void RemittanceRequestNotFoundException_예외가_반환된다() { + // given + final long requestId = 999999999999L; + + // when + final var actual = assertThrows(RemittanceRequestNotFoundException.class, () -> remittanceRequestProcessor.handleAcceptance(requestId, receiverId)); + + // then + assertThat(actual.getMessage()).isEqualTo("송금 요청 정보를 찾을 수 없습니다."); + } + } + + @Nested + class 송금_요청의_상태가_PENDING이_아닐_경우 { + @Test + void InvalidRemittanceRequestStatusException_예외가_반환된다() { + // given + final var status = RemittanceRequestStatus.ACCEPTED; + final var remittanceRequest = remittanceRequestService.getById(requestId); + remittanceRequestService.accept(remittanceRequest); + + // when + final var actual = assertThrows(InvalidRemittanceRequestStatusException.class, () -> remittanceRequestProcessor.handleAcceptance(requestId, receiverId)); + + // then + assertThat(actual.getMessage()).isEqualTo("이미 처리된 송금 요청입니다. 현재 상태는 '" + status + "'입니다."); + } + } + + @Nested + class 송금_요청의_상태가_PENDING일_경우 { + @Test + void 송금_요청이_정상적으로_수행된다() { + // when + remittanceRequestProcessor.handleAcceptance(requestId, receiverId); + + // then + final var updated = remittanceRequestService.getById(requestId); + assertThat(updated.getStatus()).isEqualTo(RemittanceRequestStatus.ACCEPTED); + } + } + + @Nested + class 수신자가_아닌_사용자가_수락하려는_경우 { + @Test + void AccessDeniedException_예외가_발생한다() { + // given + final var notReceiverEmail = "intruder@sendzy.com"; + signupService.signup(new SignupRequest(notReceiverEmail, TestUtils.DEFAULT_RAW_PASSWORD)); + final var notReceiver = memberService.findByEmail(notReceiverEmail).get(); + + // when + final var actual = assertThrows(AccessDeniedException.class, () -> + remittanceRequestProcessor.handleAcceptance(requestId, notReceiver.getId()) + ); + + // then + assertThat(actual.getMessage()).isEqualTo("해당 송금 요청의 수신자만 처리할 수 있습니다."); + } + } + } +} diff --git a/src/test/java/com/donggi/sendzy/remittance/application/RemittanceRequestRejectionTest.java b/src/test/java/com/donggi/sendzy/remittance/application/RemittanceRequestRejectionTest.java new file mode 100644 index 0000000..a0d807b --- /dev/null +++ b/src/test/java/com/donggi/sendzy/remittance/application/RemittanceRequestRejectionTest.java @@ -0,0 +1,142 @@ +package com.donggi.sendzy.remittance.application; + +import com.donggi.sendzy.account.domain.TestAccountRepository; +import com.donggi.sendzy.member.TestUtils; +import com.donggi.sendzy.member.application.SignupService; +import com.donggi.sendzy.member.domain.MemberService; +import com.donggi.sendzy.member.domain.TestMemberRepository; +import com.donggi.sendzy.member.dto.SignupRequest; +import com.donggi.sendzy.remittance.domain.RemittanceRequest; +import com.donggi.sendzy.remittance.domain.RemittanceRequestStatus; +import com.donggi.sendzy.remittance.domain.repository.TestRemittanceRequestRepository; +import com.donggi.sendzy.remittance.domain.service.RemittanceRequestService; +import com.donggi.sendzy.remittance.exception.InvalidRemittanceRequestStatusException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.access.AccessDeniedException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SuppressWarnings({"InnerClassMayBeStatic", "NonAsciiCharacters"}) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class RemittanceRequestRejectionTest { + + @Autowired + private TestMemberRepository memberRepository; + + @Autowired + private TestAccountRepository accountRepository; + + @Autowired + private TestRemittanceRequestRepository remittanceRequestRepository; + + @Autowired + private SignupService signupService; + + @Autowired + private RemittanceRequestService remittanceRequestService; + + @Autowired + private MemberService memberService; + + @Autowired + private RemittanceRequestProcessor remittanceRequestProcessor; + + private Long requestId; + private Long receiverId; + + @BeforeEach + void setUp() { + remittanceRequestRepository.deleteAll(); + accountRepository.deleteAll(); + memberRepository.deleteAll(); + + final var senderEmail = "sender@sendzy.com"; + final var receiverEmail = "receiver@sendzy.com"; + signupService.signup(new SignupRequest(senderEmail, TestUtils.DEFAULT_RAW_PASSWORD)); + signupService.signup(new SignupRequest(receiverEmail, TestUtils.DEFAULT_RAW_PASSWORD)); + + final var sender = memberService.findByEmail(senderEmail).get(); + final var receiver = memberService.findByEmail(receiverEmail).get(); + + receiverId = receiver.getId(); + + requestId = remittanceRequestService.recordRequestAndGetId( + new RemittanceRequest( + sender.getId(), + receiverId, + RemittanceRequestStatus.PENDING, + 1000L + ) + ); + } + + @AfterEach + void tearDown() { + remittanceRequestRepository.deleteAll(); + accountRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @Nested + class 송금_요청_거절_시 { + + @Nested + class 수신자가_송금_요청을_거절하면 { + @Test + void 상태가_REJECTED로_변경된다() { + // when + remittanceRequestProcessor.handleRejection(requestId, receiverId); + + // then + final var request = remittanceRequestService.getById(requestId); + assertThat(request.getStatus()).isEqualTo(RemittanceRequestStatus.REJECTED); + } + } + + @Nested + class 수신자가_아닌_회원이_거절하면 { + @Test + void AccessDeniedException_예외가_발생한다() { + // given: 수신자가 아닌 사용자 추가 + final var otherEmail = "intruder@sendzy.com"; + signupService.signup(new SignupRequest(otherEmail, TestUtils.DEFAULT_RAW_PASSWORD)); + final var otherMember = memberService.findByEmail(otherEmail).get(); + + // when + final var exception = assertThrows(AccessDeniedException.class, () -> + remittanceRequestProcessor.handleRejection(requestId, otherMember.getId()) + ); + + // then + assertThat(exception.getMessage()).isEqualTo("해당 송금 요청의 수신자만 처리할 수 있습니다."); + } + } + + @Nested + class 이미_처리된_요청은 { + @Test + void InvalidRemittanceRequestStatusException_예외가_반환된다() { + // given + final var request = remittanceRequestService.getById(requestId); + remittanceRequestService.accept(request); + + // when + final var actual = assertThrows(InvalidRemittanceRequestStatusException.class, () -> + remittanceRequestProcessor.handleRejection(requestId, receiverId) + ); + + // then + assertThat(actual.getMessage()).isEqualTo("이미 처리된 송금 요청입니다. 현재 상태는 '" + request.getStatus() + "'입니다."); + } + } + } +}