카드 매입 승인 경로 Spring Boot 3 전환 #8

Closed
forge-bot wants to merge 8 commits from forge/ACX-E2E-1785394564819-attempt-9-run-7ff7e68d0bcf into main
8 changed files with 611 additions and 0 deletions

57
pom.xml Normal file
View file

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.acquirex</groupId>
<artifactId>proj-acquirex</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>proj-acquirex</name>
<description>카드 매입 승인 시스템 - Spring Boot 3</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View file

@ -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);
}
}

View file

@ -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<ApprovalResponseDto> requestApproval(@Valid @RequestBody ApprovalRequestDto request) {
ApprovalResponseDto response = approvalService.requestApproval(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PostMapping("/{id}/approve")
public ResponseEntity<ApprovalResponseDto> approveRequest(@PathVariable Long id) {
ApprovalResponseDto response = approvalService.approveRequest(id);
return ResponseEntity.ok(response);
}
@PostMapping("/{id}/reject")
public ResponseEntity<ApprovalResponseDto> rejectRequest(@PathVariable Long id, @RequestBody Map<String, String> body) {
String reason = body.getOrDefault("reason", "사유 미지정");
ApprovalResponseDto response = approvalService.rejectRequest(id, reason);
return ResponseEntity.ok(response);
}
@GetMapping("/{id}")
public ResponseEntity<ApprovalResponseDto> getApprovalStatus(@PathVariable Long id) {
ApprovalResponseDto response = approvalService.getApprovalStatus(id);
return ResponseEntity.ok(response);
}
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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<AcquisitionCard, Long> {
Optional<AcquisitionCard> findByCardNumberAndCardCompanyCode(String cardNumber, String cardCompanyCode);
Optional<AcquisitionCard> findByMerchantNumber(String merchantNumber);
}
@Repository
interface ApprovalRequestRepository extends JpaRepository<ApprovalRequest, Long> {
Optional<ApprovalRequest> findByTransactionId(String transactionId);
List<ApprovalRequest> findByCardId(Long cardId);
List<ApprovalRequest> findByApprovalStatus(ApprovalRequest.ApprovalStatus status);
}

View file

@ -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
);
}
}

View file

@ -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());
}
}