Compare commits
8 commits
main
...
forge/ACX-
| Author | SHA1 | Date | |
|---|---|---|---|
| cd52b8e4d4 | |||
| 3f84c54d3b | |||
| b379184b90 | |||
| ac3006a215 | |||
| 6b14edeaf1 | |||
| ee64ab761b | |||
| de24053d43 | |||
| 9de20b35ac |
8 changed files with 431 additions and 0 deletions
65
pom.xml
Normal file
65
pom.xml
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?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>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</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>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
11
src/main/java/com/acquirex/AcquirexApplication.java
Normal file
11
src/main/java/com/acquirex/AcquirexApplication.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AcquisitionResponse> 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<AcquisitionResponse> getAcquisition(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(service.getAcquisition(id));
|
||||
}
|
||||
|
||||
@GetMapping("/pending")
|
||||
public ResponseEntity<List<AcquisitionResponse>> getPendingAcquisitions() {
|
||||
return ResponseEntity.ok(service.getPendingAcquisitions());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public ResponseEntity<AcquisitionResponse> approveAcquisition(@PathVariable Long id, @Valid @RequestBody ApprovalDto dto) {
|
||||
return ResponseEntity.ok(service.approveAcquisition(id, new ApprovalRequest(dto.approvedBy())));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
public ResponseEntity<AcquisitionResponse> rejectAcquisition(@PathVariable Long id, @Valid @RequestBody ApprovalDto dto) {
|
||||
return ResponseEntity.ok(service.rejectAcquisition(id, new ApprovalRequest(dto.approvedBy())));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AcquisitionNotFoundException.class)
|
||||
public ResponseEntity<Map<String, Object>> 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<Map<String, Object>> handleInvalidState(InvalidAcquisitionStateException ex) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("timestamp", LocalDateTime.now(), "status", 400, "error", ex.getMessage()));
|
||||
}
|
||||
}
|
||||
51
src/main/java/com/acquirex/domain/CardAcquisition.java
Normal file
51
src/main/java/com/acquirex/domain/CardAcquisition.java
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<CardAcquisition, Long> {
|
||||
List<CardAcquisition> findByStatus(AcquisitionStatus status);
|
||||
List<CardAcquisition> findByMerchantId(String merchantId);
|
||||
}
|
||||
|
|
@ -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<AcquisitionResponse> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/main/resources/application.yml
Normal file
27
src/main/resources/application.yml
Normal file
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue