diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..b45650c --- /dev/null +++ b/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.acquirex + proj-acquirex + 1.0.0-SNAPSHOT + proj-acquirex + 카드 매입 승인 시스템 - Spring Boot 3 + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/src/main/java/com/acquirex/AcquirexApplication.java b/src/main/java/com/acquirex/AcquirexApplication.java new file mode 100644 index 0000000..daf6bca --- /dev/null +++ b/src/main/java/com/acquirex/AcquirexApplication.java @@ -0,0 +1,11 @@ +package com.acquirex; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AcquirexApplication { + public static void main(String[] args) { + SpringApplication.run(AcquirexApplication.class, args); + } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/controller/ApprovalController.java b/src/main/java/com/acquirex/controller/ApprovalController.java new file mode 100644 index 0000000..79d9136 --- /dev/null +++ b/src/main/java/com/acquirex/controller/ApprovalController.java @@ -0,0 +1,47 @@ +package com.acquirex.controller; + +import com.acquirex.dto.ApprovalRequestDto; +import com.acquirex.dto.ApprovalResponseDto; +import com.acquirex.service.ApprovalService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/approvals") +public class ApprovalController { + + private final ApprovalService approvalService; + + public ApprovalController(ApprovalService approvalService) { + this.approvalService = approvalService; + } + + @PostMapping + public ResponseEntity requestApproval(@Valid @RequestBody ApprovalRequestDto request) { + ApprovalResponseDto response = approvalService.requestApproval(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/{id}/approve") + public ResponseEntity approveRequest(@PathVariable Long id) { + ApprovalResponseDto response = approvalService.approveRequest(id); + return ResponseEntity.ok(response); + } + + @PostMapping("/{id}/reject") + public ResponseEntity rejectRequest(@PathVariable Long id, @RequestBody Map body) { + String reason = body.getOrDefault("reason", "사유 미지정"); + ApprovalResponseDto response = approvalService.rejectRequest(id, reason); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}") + public ResponseEntity getApprovalStatus(@PathVariable Long id) { + ApprovalResponseDto response = approvalService.getApprovalStatus(id); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/domain/DomainClasses.java b/src/main/java/com/acquirex/domain/DomainClasses.java new file mode 100644 index 0000000..ffd4576 --- /dev/null +++ b/src/main/java/com/acquirex/domain/DomainClasses.java @@ -0,0 +1,131 @@ +package com.acquirex.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +// ===== AcquisitionCard Entity ===== +@Entity +@Table(name = "acquisition_cards") +class AcquisitionCard { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + @Column(name = "card_number", length = 20) + private String cardNumber; + + @NotBlank + @Column(name = "card_company_code", length = 10) + private String cardCompanyCode; + + @NotNull + @Column(name = "merchant_number", length = 15) + private String merchantNumber; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 20) + private CardStatus status = CardStatus.PENDING; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public enum CardStatus { PENDING, APPROVED, REJECTED, CANCELLED } + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getCardNumber() { return cardNumber; } + public void setCardNumber(String cardNumber) { this.cardNumber = cardNumber; } + public String getCardCompanyCode() { return cardCompanyCode; } + public void setCardCompanyCode(String cardCompanyCode) { this.cardCompanyCode = cardCompanyCode; } + public String getMerchantNumber() { return merchantNumber; } + public void setMerchantNumber(String merchantNumber) { this.merchantNumber = merchantNumber; } + public CardStatus getStatus() { return status; } + public void setStatus(CardStatus status) { this.status = status; } + public LocalDateTime getCreatedAt() { return createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } +} + +// ===== ApprovalRequest Entity ===== +@Entity +@Table(name = "approval_requests") +class ApprovalRequest { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @Column(name = "card_id") + private Long cardId; + + @NotBlank + @Column(name = "transaction_id", length = 30, unique = true) + private String transactionId; + + @NotNull + @Column(name = "amount", precision = 15, scale = 2) + private BigDecimal amount; + + @Column(name = "vat", precision = 15, scale = 2) + private BigDecimal vat; + + @Enumerated(EnumType.STRING) + @Column(name = "approval_status", length = 20) + private ApprovalStatus approvalStatus = ApprovalStatus.REQUESTED; + + @Column(name = "approval_number", length = 20) + private String approvalNumber; + + @Column(name = "requested_at") + private LocalDateTime requestedAt; + + @Column(name = "approved_at") + private LocalDateTime approvedAt; + + @Column(name = "rejection_reason", length = 200) + private String rejectionReason; + + public enum ApprovalStatus { REQUESTED, APPROVED, REJECTED, CANCELLED } + + @PrePersist + protected void onCreate() { + requestedAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getCardId() { return cardId; } + public void setCardId(Long cardId) { this.cardId = cardId; } + public String getTransactionId() { return transactionId; } + public void setTransactionId(String transactionId) { this.transactionId = transactionId; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public BigDecimal getVat() { return vat; } + public void setVat(BigDecimal vat) { this.vat = vat; } + public ApprovalStatus getApprovalStatus() { return approvalStatus; } + public void setApprovalStatus(ApprovalStatus approvalStatus) { this.approvalStatus = approvalStatus; } + public String getApprovalNumber() { return approvalNumber; } + public void setApprovalNumber(String approvalNumber) { this.approvalNumber = approvalNumber; } + public LocalDateTime getRequestedAt() { return requestedAt; } + public LocalDateTime getApprovedAt() { return approvedAt; } + public void setApprovedAt(LocalDateTime approvedAt) { this.approvedAt = approvedAt; } + public String getRejectionReason() { return rejectionReason; } + public void setRejectionReason(String rejectionReason) { this.rejectionReason = rejectionReason; } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/dto/Dtos.java b/src/main/java/com/acquirex/dto/Dtos.java new file mode 100644 index 0000000..4f835b3 --- /dev/null +++ b/src/main/java/com/acquirex/dto/Dtos.java @@ -0,0 +1,84 @@ +package com.acquirex.dto; + +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +// ===== ApprovalRequestDto ===== +class ApprovalRequestDto { + @NotBlank(message = "카드 번호는 필수입니다") + @Size(max = 20) + private String cardNumber; + + @NotBlank(message = "카드사 코드는 필수입니다") + @Size(max = 10) + private String cardCompanyCode; + + @NotBlank(message = "가맹점 번호는 필수입니다") + @Size(max = 15) + private String merchantNumber; + + @NotNull(message = "거래 금액은 필수입니다") + @DecimalMin(value = "0.01") + @Digits(integer = 13, fraction = 2) + private BigDecimal amount; + + @DecimalMin(value = "0.00") + @Digits(integer = 13, fraction = 2) + private BigDecimal vat; + + public String getCardNumber() { return cardNumber; } + public void setCardNumber(String cardNumber) { this.cardNumber = cardNumber; } + public String getCardCompanyCode() { return cardCompanyCode; } + public void setCardCompanyCode(String cardCompanyCode) { this.cardCompanyCode = cardCompanyCode; } + public String getMerchantNumber() { return merchantNumber; } + public void setMerchantNumber(String merchantNumber) { this.merchantNumber = merchantNumber; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public BigDecimal getVat() { return vat; } + public void setVat(BigDecimal vat) { this.vat = vat; } +} + +// ===== ApprovalResponseDto ===== +class ApprovalResponseDto { + private Long approvalId; + private String transactionId; + private String approvalNumber; + private String status; + private BigDecimal amount; + private BigDecimal vat; + private LocalDateTime approvedAt; + private String message; + + public ApprovalResponseDto() {} + + public ApprovalResponseDto(Long approvalId, String transactionId, String approvalNumber, + String status, BigDecimal amount, BigDecimal vat, + LocalDateTime approvedAt, String message) { + this.approvalId = approvalId; + this.transactionId = transactionId; + this.approvalNumber = approvalNumber; + this.status = status; + this.amount = amount; + this.vat = vat; + this.approvedAt = approvedAt; + this.message = message; + } + + public Long getApprovalId() { return approvalId; } + public void setApprovalId(Long approvalId) { this.approvalId = approvalId; } + public String getTransactionId() { return transactionId; } + public void setTransactionId(String transactionId) { this.transactionId = transactionId; } + public String getApprovalNumber() { return approvalNumber; } + public void setApprovalNumber(String approvalNumber) { this.approvalNumber = approvalNumber; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public BigDecimal getVat() { return vat; } + public void setVat(BigDecimal vat) { this.vat = vat; } + public LocalDateTime getApprovedAt() { return approvedAt; } + public void setApprovedAt(LocalDateTime approvedAt) { this.approvedAt = approvedAt; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/repository/Repositories.java b/src/main/java/com/acquirex/repository/Repositories.java new file mode 100644 index 0000000..6da0c2f --- /dev/null +++ b/src/main/java/com/acquirex/repository/Repositories.java @@ -0,0 +1,21 @@ +package com.acquirex.repository; + +import com.acquirex.domain.AcquisitionCard; +import com.acquirex.domain.ApprovalRequest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + +@Repository +interface AcquisitionCardRepository extends JpaRepository { + Optional findByCardNumberAndCardCompanyCode(String cardNumber, String cardCompanyCode); + Optional findByMerchantNumber(String merchantNumber); +} + +@Repository +interface ApprovalRequestRepository extends JpaRepository { + Optional findByTransactionId(String transactionId); + List findByCardId(Long cardId); + List findByApprovalStatus(ApprovalRequest.ApprovalStatus status); +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/service/ApprovalService.java b/src/main/java/com/acquirex/service/ApprovalService.java new file mode 100644 index 0000000..3dffa02 --- /dev/null +++ b/src/main/java/com/acquirex/service/ApprovalService.java @@ -0,0 +1,110 @@ +package com.acquirex.service; + +import com.acquirex.domain.AcquisitionCard; +import com.acquirex.domain.ApprovalRequest; +import com.acquirex.dto.ApprovalRequestDto; +import com.acquirex.dto.ApprovalResponseDto; +import com.acquirex.repository.AcquisitionCardRepository; +import com.acquirex.repository.ApprovalRequestRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +public class ApprovalService { + + private final ApprovalRequestRepository approvalRequestRepository; + private final AcquisitionCardRepository acquisitionCardRepository; + + public ApprovalService(ApprovalRequestRepository approvalRequestRepository, + AcquisitionCardRepository acquisitionCardRepository) { + this.approvalRequestRepository = approvalRequestRepository; + this.acquisitionCardRepository = acquisitionCardRepository; + } + + @Transactional + public ApprovalResponseDto requestApproval(ApprovalRequestDto dto) { + String transactionId = generateTransactionId(); + + AcquisitionCard card = acquisitionCardRepository + .findByCardNumberAndCardCompanyCode(dto.getCardNumber(), dto.getCardCompanyCode()) + .orElseGet(() -> createNewCard(dto)); + + ApprovalRequest request = new ApprovalRequest(); + request.setCardId(card.getId()); + request.setTransactionId(transactionId); + request.setAmount(dto.getAmount()); + request.setVat(dto.getVat()); + request.setApprovalStatus(ApprovalRequest.ApprovalStatus.REQUESTED); + + ApprovalRequest saved = approvalRequestRepository.save(request); + return buildApprovalResponse(saved, "매입 승인 요청이 완료되었습니다"); + } + + @Transactional + public ApprovalResponseDto approveRequest(Long requestId) { + ApprovalRequest request = approvalRequestRepository.findById(requestId) + .orElseThrow(() -> new IllegalArgumentException("승인 요청을 찾을 수 없습니다: " + requestId)); + + if (request.getApprovalStatus() != ApprovalRequest.ApprovalStatus.REQUESTED) { + throw new IllegalStateException("이미 처리된 요청입니다"); + } + + request.setApprovalNumber(generateApprovalNumber()); + request.setApprovalStatus(ApprovalRequest.ApprovalStatus.APPROVED); + request.setApprovedAt(LocalDateTime.now()); + + ApprovalRequest saved = approvalRequestRepository.save(request); + return buildApprovalResponse(saved, "매입 승인이 완료되었습니다"); + } + + @Transactional + public ApprovalResponseDto rejectRequest(Long requestId, String reason) { + ApprovalRequest request = approvalRequestRepository.findById(requestId) + .orElseThrow(() -> new IllegalArgumentException("승인 요청을 찾을 수 없습니다: " + requestId)); + + if (request.getApprovalStatus() != ApprovalRequest.ApprovalStatus.REQUESTED) { + throw new IllegalStateException("이미 처리된 요청입니다"); + } + + request.setApprovalStatus(ApprovalRequest.ApprovalStatus.REJECTED); + request.setRejectionReason(reason); + + ApprovalRequest saved = approvalRequestRepository.save(request); + return buildApprovalResponse(saved, "매입 승인이 거절되었습니다"); + } + + @Transactional(readOnly = true) + public ApprovalResponseDto getApprovalStatus(Long requestId) { + ApprovalRequest request = approvalRequestRepository.findById(requestId) + .orElseThrow(() -> new IllegalArgumentException("승인 요청을 찾을 수 없습니다: " + requestId)); + return buildApprovalResponse(request, "조회가 완료되었습니다"); + } + + private AcquisitionCard createNewCard(ApprovalRequestDto dto) { + AcquisitionCard card = new AcquisitionCard(); + card.setCardNumber(dto.getCardNumber()); + card.setCardCompanyCode(dto.getCardCompanyCode()); + card.setMerchantNumber(dto.getMerchantNumber()); + card.setStatus(AcquisitionCard.CardStatus.PENDING); + return acquisitionCardRepository.save(card); + } + + private String generateTransactionId() { + return "TX" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } + + private String generateApprovalNumber() { + return "AP" + System.currentTimeMillis(); + } + + private ApprovalResponseDto buildApprovalResponse(ApprovalRequest request, String message) { + return new ApprovalResponseDto( + request.getId(), request.getTransactionId(), request.getApprovalNumber(), + request.getApprovalStatus().name(), request.getAmount(), request.getVat(), + request.getApprovedAt(), message + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/acquirex/ApprovalServiceTest.java b/src/test/java/com/acquirex/ApprovalServiceTest.java new file mode 100644 index 0000000..5facdf5 --- /dev/null +++ b/src/test/java/com/acquirex/ApprovalServiceTest.java @@ -0,0 +1,150 @@ +package com.acquirex; + +import com.acquirex.domain.AcquisitionCard; +import com.acquirex.domain.ApprovalRequest; +import com.acquirex.dto.ApprovalRequestDto; +import com.acquirex.dto.ApprovalResponseDto; +import com.acquirex.repository.AcquisitionCardRepository; +import com.acquirex.repository.ApprovalRequestRepository; +import com.acquirex.service.ApprovalService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ApprovalServiceTest { + + @Mock + private ApprovalRequestRepository approvalRequestRepository; + + @Mock + private AcquisitionCardRepository acquisitionCardRepository; + + private ApprovalService approvalService; + private ApprovalRequestDto validRequestDto; + private AcquisitionCard testCard; + private ApprovalRequest testRequest; + + @BeforeEach + void setUp() { + approvalService = new ApprovalService(approvalRequestRepository, acquisitionCardRepository); + + validRequestDto = new ApprovalRequestDto(); + validRequestDto.setCardNumber("1234567890123456"); + validRequestDto.setCardCompanyCode("KB"); + validRequestDto.setMerchantNumber("123456789012345"); + validRequestDto.setAmount(new BigDecimal("50000.00")); + validRequestDto.setVat(new BigDecimal("5000.00")); + + testCard = new AcquisitionCard(); + testCard.setId(1L); + testCard.setCardNumber("1234567890123456"); + testCard.setCardCompanyCode("KB"); + testCard.setMerchantNumber("123456789012345"); + testCard.setStatus(AcquisitionCard.CardStatus.PENDING); + + testRequest = new ApprovalRequest(); + testRequest.setId(1L); + testRequest.setCardId(1L); + testRequest.setTransactionId("TX12345678"); + testRequest.setAmount(new BigDecimal("50000.00")); + testRequest.setVat(new BigDecimal("5000.00")); + testRequest.setApprovalStatus(ApprovalRequest.ApprovalStatus.REQUESTED); + } + + @Test + @DisplayName("매입 승인 요청 성공 - 기존 카드") + void requestApproval_WithExistingCard_Success() { + when(acquisitionCardRepository.findByCardNumberAndCardCompanyCode(any(), any())) + .thenReturn(Optional.of(testCard)); + when(approvalRequestRepository.save(any(ApprovalRequest.class))).thenReturn(testRequest); + + ApprovalResponseDto response = approvalService.requestApproval(validRequestDto); + + assertNotNull(response); + assertEquals("TX12345678", response.getTransactionId()); + assertEquals("REQUESTED", response.getStatus()); + verify(acquisitionCardRepository, never()).save(any()); + verify(approvalRequestRepository, times(1)).save(any()); + } + + @Test + @DisplayName("매입 승인 요청 성공 - 신규 카드") + void requestApproval_WithNewCard_Success() { + when(acquisitionCardRepository.findByCardNumberAndCardCompanyCode(any(), any())) + .thenReturn(Optional.empty()); + when(acquisitionCardRepository.save(any(AcquisitionCard.class))).thenReturn(testCard); + when(approvalRequestRepository.save(any(ApprovalRequest.class))).thenReturn(testRequest); + + ApprovalResponseDto response = approvalService.requestApproval(validRequestDto); + + assertNotNull(response); + assertEquals("REQUESTED", response.getStatus()); + verify(acquisitionCardRepository, times(1)).save(any()); + } + + @Test + @DisplayName("매입 승인 처리 성공") + void approveRequest_Success() { + when(approvalRequestRepository.findById(1L)).thenReturn(Optional.of(testRequest)); + when(approvalRequestRepository.save(any(ApprovalRequest.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + ApprovalResponseDto response = approvalService.approveRequest(1L); + + assertNotNull(response); + assertEquals("APPROVED", response.getStatus()); + assertNotNull(response.getApprovalNumber()); + assertNotNull(response.getApprovedAt()); + } + + @Test + @DisplayName("매입 승인 처리 실패 - 요청 없음") + void approveRequest_NotFound() { + when(approvalRequestRepository.findById(999L)).thenReturn(Optional.empty()); + assertThrows(IllegalArgumentException.class, () -> approvalService.approveRequest(999L)); + } + + @Test + @DisplayName("매입 승인 처리 실패 - 이미 처리됨") + void approveRequest_AlreadyProcessed() { + testRequest.setApprovalStatus(ApprovalRequest.ApprovalStatus.APPROVED); + when(approvalRequestRepository.findById(1L)).thenReturn(Optional.of(testRequest)); + assertThrows(IllegalStateException.class, () -> approvalService.approveRequest(1L)); + } + + @Test + @DisplayName("매입 승인 거절 성공") + void rejectRequest_Success() { + when(approvalRequestRepository.findById(1L)).thenReturn(Optional.of(testRequest)); + when(approvalRequestRepository.save(any(ApprovalRequest.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + ApprovalResponseDto response = approvalService.rejectRequest(1L, "한도 초과"); + + assertNotNull(response); + assertEquals("REJECTED", response.getStatus()); + } + + @Test + @DisplayName("승인 상태 조회 성공") + void getApprovalStatus_Success() { + when(approvalRequestRepository.findById(1L)).thenReturn(Optional.of(testRequest)); + + ApprovalResponseDto response = approvalService.getApprovalStatus(1L); + + assertNotNull(response); + assertEquals(1L, response.getApprovalId()); + assertEquals("TX12345678", response.getTransactionId()); + } +} \ No newline at end of file