diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..a22ec0d --- /dev/null +++ b/pom.xml @@ -0,0 +1,65 @@ + + + 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 + + + com.h2database + h2 + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + \ 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/CardAcquisitionController.java b/src/main/java/com/acquirex/controller/CardAcquisitionController.java new file mode 100644 index 0000000..0830b3f --- /dev/null +++ b/src/main/java/com/acquirex/controller/CardAcquisitionController.java @@ -0,0 +1,70 @@ +package com.acquirex.controller; + +import com.acquirex.service.CardAcquisitionService; +import com.acquirex.service.CardAcquisitionService.AcquisitionRequest; +import com.acquirex.service.CardAcquisitionService.AcquisitionResponse; +import com.acquirex.service.CardAcquisitionService.ApprovalRequest; +import com.acquirex.service.CardAcquisitionService.AcquisitionNotFoundException; +import com.acquirex.service.CardAcquisitionService.InvalidAcquisitionStateException; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/acquisitions") +@RequiredArgsConstructor +public class CardAcquisitionController { + private final CardAcquisitionService service; + + public record RequestDto( + @NotBlank(message = "카드번호 필수") @Size(min = 13, max = 19) String cardNumber, + @NotBlank @Size(max = 20) String merchantId, + @NotNull @DecimalMin("0.01") BigDecimal amount) {} + + public record ApprovalDto(@NotBlank @Size(max = 50) String approvedBy) {} + + @PostMapping + public ResponseEntity requestAcquisition(@Valid @RequestBody RequestDto dto) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(service.requestAcquisition(new AcquisitionRequest(dto.cardNumber(), dto.merchantId(), dto.amount()))); + } + + @GetMapping("/{id}") + public ResponseEntity getAcquisition(@PathVariable Long id) { + return ResponseEntity.ok(service.getAcquisition(id)); + } + + @GetMapping("/pending") + public ResponseEntity> getPendingAcquisitions() { + return ResponseEntity.ok(service.getPendingAcquisitions()); + } + + @PostMapping("/{id}/approve") + public ResponseEntity approveAcquisition(@PathVariable Long id, @Valid @RequestBody ApprovalDto dto) { + return ResponseEntity.ok(service.approveAcquisition(id, new ApprovalRequest(dto.approvedBy()))); + } + + @PostMapping("/{id}/reject") + public ResponseEntity rejectAcquisition(@PathVariable Long id, @Valid @RequestBody ApprovalDto dto) { + return ResponseEntity.ok(service.rejectAcquisition(id, new ApprovalRequest(dto.approvedBy()))); + } + + @ExceptionHandler(AcquisitionNotFoundException.class) + public ResponseEntity> handleNotFound(AcquisitionNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("timestamp", LocalDateTime.now(), "status", 404, "error", ex.getMessage())); + } + + @ExceptionHandler(InvalidAcquisitionStateException.class) + public ResponseEntity> handleInvalidState(InvalidAcquisitionStateException ex) { + return ResponseEntity.badRequest() + .body(Map.of("timestamp", LocalDateTime.now(), "status", 400, "error", ex.getMessage())); + } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/domain/CardAcquisition.java b/src/main/java/com/acquirex/domain/CardAcquisition.java new file mode 100644 index 0000000..42fd37b --- /dev/null +++ b/src/main/java/com/acquirex/domain/CardAcquisition.java @@ -0,0 +1,51 @@ +package com.acquirex.domain; + +import jakarta.persistence.*; +import lombok.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "card_acquisitions") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CardAcquisition { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "card_number", nullable = false, length = 20) + private String cardNumber; + + @Column(name = "merchant_id", nullable = false, length = 20) + private String merchantId; + + @Column(name = "amount", nullable = false, precision = 15, scale = 2) + private BigDecimal amount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private AcquisitionStatus status; + + @Column(name = "requested_at", nullable = false) + private LocalDateTime requestedAt; + + @Column(name = "approved_at") + private LocalDateTime approvedAt; + + @Column(name = "approved_by", length = 50) + private String approvedBy; + + @PrePersist + protected void onCreate() { + if (requestedAt == null) requestedAt = LocalDateTime.now(); + if (status == null) status = AcquisitionStatus.PENDING; + } +} + +enum AcquisitionStatus { + PENDING, APPROVED, REJECTED, CANCELLED +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/repository/CardAcquisitionRepository.java b/src/main/java/com/acquirex/repository/CardAcquisitionRepository.java new file mode 100644 index 0000000..21e3a24 --- /dev/null +++ b/src/main/java/com/acquirex/repository/CardAcquisitionRepository.java @@ -0,0 +1,13 @@ +package com.acquirex.repository; + +import com.acquirex.domain.AcquisitionStatus; +import com.acquirex.domain.CardAcquisition; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface CardAcquisitionRepository extends JpaRepository { + List findByStatus(AcquisitionStatus status); + List findByMerchantId(String merchantId); +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/service/CardAcquisitionService.java b/src/main/java/com/acquirex/service/CardAcquisitionService.java new file mode 100644 index 0000000..f983b37 --- /dev/null +++ b/src/main/java/com/acquirex/service/CardAcquisitionService.java @@ -0,0 +1,91 @@ +package com.acquirex.service; + +import com.acquirex.domain.AcquisitionStatus; +import com.acquirex.domain.CardAcquisition; +import com.acquirex.repository.CardAcquisitionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CardAcquisitionService { + private final CardAcquisitionRepository repository; + + public record AcquisitionRequest(String cardNumber, String merchantId, BigDecimal amount) {} + public record ApprovalRequest(String approvedBy) {} + public record AcquisitionResponse( + Long id, String cardNumber, String merchantId, BigDecimal amount, + AcquisitionStatus status, LocalDateTime requestedAt, + LocalDateTime approvedAt, String approvedBy) {} + + @Transactional + public AcquisitionResponse requestAcquisition(AcquisitionRequest request) { + log.info("매입 요청: merchantId={}, amount={}", request.merchantId(), request.amount()); + CardAcquisition entity = CardAcquisition.builder() + .cardNumber(request.cardNumber()) + .merchantId(request.merchantId()) + .amount(request.amount()) + .status(AcquisitionStatus.PENDING) + .requestedAt(LocalDateTime.now()) + .build(); + CardAcquisition saved = repository.save(entity); + return toResponse(saved); + } + + @Transactional(readOnly = true) + public AcquisitionResponse getAcquisition(Long id) { + return repository.findById(id).map(this::toResponse) + .orElseThrow(() -> new AcquisitionNotFoundException(id)); + } + + @Transactional(readOnly = true) + public List getPendingAcquisitions() { + return repository.findByStatus(AcquisitionStatus.PENDING).stream().map(this::toResponse).toList(); + } + + @Transactional + public AcquisitionResponse approveAcquisition(Long id, ApprovalRequest request) { + log.info("매입 승인: id={}, approvedBy={}", id, request.approvedBy()); + CardAcquisition entity = repository.findById(id).orElseThrow(() -> new AcquisitionNotFoundException(id)); + if (entity.getStatus() != AcquisitionStatus.PENDING) { + throw new InvalidAcquisitionStateException(id, entity.getStatus()); + } + entity.setStatus(AcquisitionStatus.APPROVED); + entity.setApprovedAt(LocalDateTime.now()); + entity.setApprovedBy(request.approvedBy()); + return toResponse(repository.save(entity)); + } + + @Transactional + public AcquisitionResponse rejectAcquisition(Long id, ApprovalRequest request) { + log.info("매입 거절: id={}, rejectedBy={}", id, request.approvedBy()); + CardAcquisition entity = repository.findById(id).orElseThrow(() -> new AcquisitionNotFoundException(id)); + if (entity.getStatus() != AcquisitionStatus.PENDING) { + throw new InvalidAcquisitionStateException(id, entity.getStatus()); + } + entity.setStatus(AcquisitionStatus.REJECTED); + entity.setApprovedAt(LocalDateTime.now()); + entity.setApprovedBy(request.approvedBy()); + return toResponse(repository.save(entity)); + } + + private AcquisitionResponse toResponse(CardAcquisition e) { + return new AcquisitionResponse(e.getId(), e.getCardNumber(), e.getMerchantId(), + e.getAmount(), e.getStatus(), e.getRequestedAt(), e.getApprovedAt(), e.getApprovedBy()); + } + + public static class AcquisitionNotFoundException extends RuntimeException { + public AcquisitionNotFoundException(Long id) { super("매입 요청을 찾을 수 없습니다: id=" + id); } + } + public static class InvalidAcquisitionStateException extends RuntimeException { + public InvalidAcquisitionStateException(Long id, AcquisitionStatus status) { + super("유효하지 않은 상태: id=" + id + ", status=" + status); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..66be359 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,27 @@ +spring: + application: + name: proj-acquirex + datasource: + url: jdbc:h2:mem:acquirex;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + h2: + console: + enabled: true + path: /h2-console + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + +server: + port: 8080 + +logging: + level: + com.acquirex: DEBUG + org.hibernate.SQL: DEBUG diff --git a/src/test/java/com/acquirex/service/CardAcquisitionServiceTest.java b/src/test/java/com/acquirex/service/CardAcquisitionServiceTest.java new file mode 100644 index 0000000..ce3c57e --- /dev/null +++ b/src/test/java/com/acquirex/service/CardAcquisitionServiceTest.java @@ -0,0 +1,103 @@ +package com.acquirex.service; + +import com.acquirex.domain.AcquisitionStatus; +import com.acquirex.domain.CardAcquisition; +import com.acquirex.repository.CardAcquisitionRepository; +import com.acquirex.service.CardAcquisitionService.AcquisitionRequest; +import com.acquirex.service.CardAcquisitionService.AcquisitionResponse; +import com.acquirex.service.CardAcquisitionService.ApprovalRequest; +import com.acquirex.service.CardAcquisitionService.AcquisitionNotFoundException; +import com.acquirex.service.CardAcquisitionService.InvalidAcquisitionStateException; +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.Optional; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CardAcquisitionServiceTest { + @Mock private CardAcquisitionRepository repository; + @InjectMocks private CardAcquisitionService service; + + private CardAcquisition pendingAcquisition; + + @BeforeEach + void setUp() { + pendingAcquisition = CardAcquisition.builder() + .id(1L).cardNumber("1234567890123456").merchantId("MERCHANT001") + .amount(new BigDecimal("50000.00")).status(AcquisitionStatus.PENDING) + .requestedAt(LocalDateTime.now()).build(); + } + + @Test + @DisplayName("매입 요청 저장 성공") + void requestAcquisition_Success() { + when(repository.save(any(CardAcquisition.class))).thenReturn(pendingAcquisition); + AcquisitionResponse result = service.requestAcquisition( + new AcquisitionRequest("1234567890123456", "MERCHANT001", new BigDecimal("50000.00"))); + assertThat(result.id()).isEqualTo(1L); + assertThat(result.status()).isEqualTo(AcquisitionStatus.PENDING); + verify(repository).save(any(CardAcquisition.class)); + } + + @Test + @DisplayName("매입 요청 조회 성공") + void getAcquisition_Success() { + when(repository.findById(1L)).thenReturn(Optional.of(pendingAcquisition)); + AcquisitionResponse result = service.getAcquisition(1L); + assertThat(result.id()).isEqualTo(1L); + } + + @Test + @DisplayName("매입 요청 조회 실패 - 존재하지 않음") + void getAcquisition_NotFound() { + when(repository.findById(999L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.getAcquisition(999L)) + .isInstanceOf(AcquisitionNotFoundException.class); + } + + @Test + @DisplayName("매입 승인 성공") + void approveAcquisition_Success() { + when(repository.findById(1L)).thenReturn(Optional.of(pendingAcquisition)); + when(repository.save(any(CardAcquisition.class))).thenAnswer(i -> i.getArgument(0)); + AcquisitionResponse result = service.approveAcquisition(1L, new ApprovalRequest("ADMIN001")); + assertThat(result.status()).isEqualTo(AcquisitionStatus.APPROVED); + assertThat(result.approvedBy()).isEqualTo("ADMIN001"); + } + + @Test + @DisplayName("매입 승인 실패 - 이미 처리됨") + void approveAcquisition_AlreadyProcessed() { + pendingAcquisition.setStatus(AcquisitionStatus.APPROVED); + when(repository.findById(1L)).thenReturn(Optional.of(pendingAcquisition)); + assertThatThrownBy(() -> service.approveAcquisition(1L, new ApprovalRequest("ADMIN001"))) + .isInstanceOf(InvalidAcquisitionStateException.class); + } + + @Test + @DisplayName("매입 거절 성공") + void rejectAcquisition_Success() { + when(repository.findById(1L)).thenReturn(Optional.of(pendingAcquisition)); + when(repository.save(any(CardAcquisition.class))).thenAnswer(i -> i.getArgument(0)); + AcquisitionResponse result = service.rejectAcquisition(1L, new ApprovalRequest("ADMIN001")); + assertThat(result.status()).isEqualTo(AcquisitionStatus.REJECTED); + } + + @Test + @DisplayName("대기 중인 요청 목록 조회") + void getPendingAcquisitions_Success() { + when(repository.findByStatus(AcquisitionStatus.PENDING)).thenReturn(java.util.List.of(pendingAcquisition)); + var result = service.getPendingAcquisitions(); + assertThat(result).hasSize(1); + assertThat(result.get(0).status()).isEqualTo(AcquisitionStatus.PENDING); + } +} \ No newline at end of file