카드 매입 승인 경로 Spring Boot 3 전환 #3
8 changed files with 497 additions and 0 deletions
57
pom.xml
Normal file
57
pom.xml
Normal 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>card-acquisition</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<name>card-acquisition</name>
|
||||
<description>카드 매입 승인 경로 - Spring Boot 3 Migration</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>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.acquirex.cardacquisition;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class CardAcquisitionApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(CardAcquisitionApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.acquirex.cardacquisition.controller;
|
||||
|
||||
import com.acquirex.cardacquisition.dto.ApprovalDto.ApprovalRequest;
|
||||
import com.acquirex.cardacquisition.dto.ApprovalDto.ApprovalResponse;
|
||||
import com.acquirex.cardacquisition.service.AcquisitionApprovalService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/acquisitions")
|
||||
public class AcquisitionApprovalController {
|
||||
|
||||
private final AcquisitionApprovalService service;
|
||||
|
||||
public AcquisitionApprovalController(AcquisitionApprovalService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApprovalResponse> requestApproval(@Valid @RequestBody ApprovalRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(service.requestApproval(request));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public ResponseEntity<ApprovalResponse> approve(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(service.approve(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
public ResponseEntity<ApprovalResponse> reject(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(service.reject(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApprovalResponse> getApproval(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(service.getApproval(id));
|
||||
}
|
||||
|
||||
@GetMapping("/pending")
|
||||
public ResponseEntity<List<ApprovalResponse>> getPendingApprovals() {
|
||||
return ResponseEntity.ok(service.getPendingApprovals());
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleNotFound(IllegalArgumentException ex) {
|
||||
return buildError(HttpStatus.NOT_FOUND, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalStateException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleConflict(IllegalStateException ex) {
|
||||
return buildError(HttpStatus.CONFLICT, ex.getMessage());
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> buildError(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("timestamp", LocalDateTime.now());
|
||||
body.put("status", status.value());
|
||||
body.put("error", status.getReasonPhrase());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package com.acquirex.cardacquisition.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "acquisition_approvals")
|
||||
public class AcquisitionApproval {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "card_number", nullable = false, length = 16)
|
||||
private String cardNumber;
|
||||
|
||||
@Column(name = "merchant_id", nullable = false)
|
||||
private String merchantId;
|
||||
|
||||
@Column(name = "amount", nullable = false, precision = 15, scale = 2)
|
||||
private BigDecimal amount;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false)
|
||||
private ApprovalStatus status;
|
||||
|
||||
@Column(name = "approval_code", length = 12)
|
||||
private String approvalCode;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public enum ApprovalStatus {
|
||||
PENDING, APPROVED, REJECTED, CANCELLED
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
if (status == null) {
|
||||
status = ApprovalStatus.PENDING;
|
||||
}
|
||||
}
|
||||
|
||||
@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 getMerchantId() { return merchantId; }
|
||||
public void setMerchantId(String merchantId) { this.merchantId = merchantId; }
|
||||
public BigDecimal getAmount() { return amount; }
|
||||
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||
public ApprovalStatus getStatus() { return status; }
|
||||
public void setStatus(ApprovalStatus status) { this.status = status; }
|
||||
public String getApprovalCode() { return approvalCode; }
|
||||
public void setApprovalCode(String approvalCode) { this.approvalCode = approvalCode; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.acquirex.cardacquisition.dto;
|
||||
|
||||
import com.acquirex.cardacquisition.domain.AcquisitionApproval.ApprovalStatus;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public sealed interface ApprovalDto permits ApprovalRequest, ApprovalResponse {}
|
||||
|
||||
public record ApprovalRequest(
|
||||
@NotBlank(message = "카드번호는 필수입니다")
|
||||
@Size(min = 16, max = 16, message = "카드번호는 16자리입니다")
|
||||
String cardNumber,
|
||||
|
||||
@NotBlank(message = "가맹점ID는 필수입니다")
|
||||
String merchantId,
|
||||
|
||||
@NotNull(message = "금액은 필수입니다")
|
||||
@DecimalMin(value = "0.01", message = "최소 금액은 0.01입니다")
|
||||
@DecimalMax(value = "999999999.99", message = "최대 금액을 초과했습니다")
|
||||
BigDecimal amount
|
||||
) implements ApprovalDto {}
|
||||
|
||||
public record ApprovalResponse(
|
||||
Long id,
|
||||
String cardNumber,
|
||||
String merchantId,
|
||||
BigDecimal amount,
|
||||
ApprovalStatus status,
|
||||
String approvalCode,
|
||||
LocalDateTime createdAt
|
||||
) implements ApprovalDto {}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.acquirex.cardacquisition.repository;
|
||||
|
||||
import com.acquirex.cardacquisition.domain.AcquisitionApproval;
|
||||
import com.acquirex.cardacquisition.domain.AcquisitionApproval.ApprovalStatus;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface AcquisitionApprovalRepository extends JpaRepository<AcquisitionApproval, Long> {
|
||||
|
||||
List<AcquisitionApproval> findByStatus(ApprovalStatus status);
|
||||
|
||||
List<AcquisitionApproval> findByMerchantId(String merchantId);
|
||||
|
||||
Optional<AcquisitionApproval> findByApprovalCode(String approvalCode);
|
||||
|
||||
List<AcquisitionApproval> findByCardNumberAndStatus(String cardNumber, ApprovalStatus status);
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package com.acquirex.cardacquisition.service;
|
||||
|
||||
import com.acquirex.cardacquisition.domain.AcquisitionApproval;
|
||||
import com.acquirex.cardacquisition.domain.AcquisitionApproval.ApprovalStatus;
|
||||
import com.acquirex.cardacquisition.dto.ApprovalDto.ApprovalRequest;
|
||||
import com.acquirex.cardacquisition.dto.ApprovalDto.ApprovalResponse;
|
||||
import com.acquirex.cardacquisition.repository.AcquisitionApprovalRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class AcquisitionApprovalService {
|
||||
|
||||
private final AcquisitionApprovalRepository repository;
|
||||
|
||||
public AcquisitionApprovalService(AcquisitionApprovalRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ApprovalResponse requestApproval(ApprovalRequest request) {
|
||||
AcquisitionApproval approval = new AcquisitionApproval();
|
||||
approval.setCardNumber(request.cardNumber());
|
||||
approval.setMerchantId(request.merchantId());
|
||||
approval.setAmount(request.amount());
|
||||
approval.setStatus(ApprovalStatus.PENDING);
|
||||
|
||||
AcquisitionApproval saved = repository.save(approval);
|
||||
return toResponse(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ApprovalResponse approve(Long id) {
|
||||
AcquisitionApproval approval = repository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("승인 요청을 찾을 수 없습니다: " + id));
|
||||
|
||||
if (approval.getStatus() != ApprovalStatus.PENDING) {
|
||||
throw new IllegalStateException("대기 상태의 요청만 승인 가능합니다");
|
||||
}
|
||||
|
||||
approval.setStatus(ApprovalStatus.APPROVED);
|
||||
approval.setApprovalCode(generateApprovalCode());
|
||||
|
||||
AcquisitionApproval saved = repository.save(approval);
|
||||
return toResponse(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ApprovalResponse reject(Long id) {
|
||||
AcquisitionApproval approval = repository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("승인 요청을 찾을 수 없습니다: " + id));
|
||||
|
||||
if (approval.getStatus() != ApprovalStatus.PENDING) {
|
||||
throw new IllegalStateException("대기 상태의 요청만 거절 가능합니다");
|
||||
}
|
||||
|
||||
approval.setStatus(ApprovalStatus.REJECTED);
|
||||
|
||||
AcquisitionApproval saved = repository.save(approval);
|
||||
return toResponse(saved);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ApprovalResponse> getPendingApprovals() {
|
||||
return repository.findByStatus(ApprovalStatus.PENDING)
|
||||
.stream().map(this::toResponse).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ApprovalResponse getApproval(Long id) {
|
||||
return repository.findById(id)
|
||||
.map(this::toResponse)
|
||||
.orElseThrow(() -> new IllegalArgumentException("승인 요청을 찾을 수 없습니다: " + id));
|
||||
}
|
||||
|
||||
private String generateApprovalCode() {
|
||||
return "APX" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
private ApprovalResponse toResponse(AcquisitionApproval approval) {
|
||||
return new ApprovalResponse(
|
||||
approval.getId(),
|
||||
maskCardNumber(approval.getCardNumber()),
|
||||
approval.getMerchantId(),
|
||||
approval.getAmount(),
|
||||
approval.getStatus(),
|
||||
approval.getApprovalCode(),
|
||||
approval.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private String maskCardNumber(String cardNumber) {
|
||||
if (cardNumber == null || cardNumber.length() < 4) return cardNumber;
|
||||
return "****-****-****-" + cardNumber.substring(cardNumber.length() - 4);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
package com.acquirex.cardacquisition.service;
|
||||
|
||||
import com.acquirex.cardacquisition.domain.AcquisitionApproval;
|
||||
import com.acquirex.cardacquisition.domain.AcquisitionApproval.ApprovalStatus;
|
||||
import com.acquirex.cardacquisition.dto.ApprovalDto.ApprovalRequest;
|
||||
import com.acquirex.cardacquisition.dto.ApprovalDto.ApprovalResponse;
|
||||
import com.acquirex.cardacquisition.repository.AcquisitionApprovalRepository;
|
||||
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.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AcquisitionApprovalServiceTest {
|
||||
|
||||
@Mock
|
||||
private AcquisitionApprovalRepository repository;
|
||||
|
||||
@InjectMocks
|
||||
private AcquisitionApprovalService service;
|
||||
|
||||
private ApprovalRequest validRequest;
|
||||
private AcquisitionApproval pendingApproval;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
validRequest = new ApprovalRequest("1234567890123456", "MERCHANT001", new BigDecimal("50000.00"));
|
||||
|
||||
pendingApproval = new AcquisitionApproval();
|
||||
pendingApproval.setId(1L);
|
||||
pendingApproval.setCardNumber("1234567890123456");
|
||||
pendingApproval.setMerchantId("MERCHANT001");
|
||||
pendingApproval.setAmount(new BigDecimal("50000.00"));
|
||||
pendingApproval.setStatus(ApprovalStatus.PENDING);
|
||||
pendingApproval.setCreatedAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("매입 승인 요청 생성 성공")
|
||||
void requestApproval_Success() {
|
||||
when(repository.save(any(AcquisitionApproval.class))).thenReturn(pendingApproval);
|
||||
|
||||
ApprovalResponse response = service.requestApproval(validRequest);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.id()).isEqualTo(1L);
|
||||
assertThat(response.status()).isEqualTo(ApprovalStatus.PENDING);
|
||||
assertThat(response.cardNumber()).isEqualTo("****-****-****-3456");
|
||||
verify(repository, times(1)).save(any(AcquisitionApproval.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("매입 승인 처리 성공")
|
||||
void approve_Success() {
|
||||
AcquisitionApproval approved = new AcquisitionApproval();
|
||||
approved.setId(1L);
|
||||
approved.setCardNumber("1234567890123456");
|
||||
approved.setMerchantId("MERCHANT001");
|
||||
approved.setAmount(new BigDecimal("50000.00"));
|
||||
approved.setStatus(ApprovalStatus.APPROVED);
|
||||
approved.setApprovalCode("APX12345678");
|
||||
approved.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
when(repository.findById(1L)).thenReturn(Optional.of(pendingApproval));
|
||||
when(repository.save(any(AcquisitionApproval.class))).thenReturn(approved);
|
||||
|
||||
ApprovalResponse response = service.approve(1L);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.status()).isEqualTo(ApprovalStatus.APPROVED);
|
||||
assertThat(response.approvalCode()).isNotNull();
|
||||
assertThat(response.approvalCode()).startsWith("APX");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("대기 상태가 아닌 요청은 승인 불가")
|
||||
void approve_NotPending_ThrowsException() {
|
||||
pendingApproval.setStatus(ApprovalStatus.APPROVED);
|
||||
when(repository.findById(1L)).thenReturn(Optional.of(pendingApproval));
|
||||
|
||||
assertThatThrownBy(() -> service.approve(1L))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("대기 상태의 요청만 승인 가능");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("매입 거절 처리 성공")
|
||||
void reject_Success() {
|
||||
AcquisitionApproval rejected = new AcquisitionApproval();
|
||||
rejected.setId(1L);
|
||||
rejected.setCardNumber("1234567890123456");
|
||||
rejected.setMerchantId("MERCHANT001");
|
||||
rejected.setAmount(new BigDecimal("50000.00"));
|
||||
rejected.setStatus(ApprovalStatus.REJECTED);
|
||||
rejected.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
when(repository.findById(1L)).thenReturn(Optional.of(pendingApproval));
|
||||
when(repository.save(any(AcquisitionApproval.class))).thenReturn(rejected);
|
||||
|
||||
ApprovalResponse response = service.reject(1L);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.status()).isEqualTo(ApprovalStatus.REJECTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("존재하지 않는 요청 조회 시 예외 발생")
|
||||
void getApproval_NotFound_ThrowsException() {
|
||||
when(repository.findById(999L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.getApproval(999L))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("찾을 수 없습니다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("대기 중인 승인 목록 조회")
|
||||
void getPendingApprovals_Success() {
|
||||
when(repository.findByStatus(ApprovalStatus.PENDING)).thenReturn(List.of(pendingApproval));
|
||||
|
||||
List<ApprovalResponse> responses = service.getPendingApprovals();
|
||||
|
||||
assertThat(responses).hasSize(1);
|
||||
assertThat(responses.get(0).status()).isEqualTo(ApprovalStatus.PENDING);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue