Compare commits

...
Sign in to create a new pull request.

8 commits

Author SHA1 Message Date
cd52b8e4d4 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
All checks were successful
Verify / source-contract (push) Successful in 11s
Verify / source-contract (pull_request) Successful in 12s
2026-07-30 13:41:04 +00:00
3f84c54d3b 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 13:41:02 +00:00
b379184b90 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks failed
Verify / source-contract (push) Has been cancelled
2026-07-30 13:41:01 +00:00
ac3006a215 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 13:41:00 +00:00
6b14edeaf1 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 13:40:58 +00:00
ee64ab761b 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 13:40:57 +00:00
de24053d43 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks failed
Verify / source-contract (push) Has been cancelled
2026-07-30 13:40:56 +00:00
9de20b35ac 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 13:40:55 +00:00
8 changed files with 431 additions and 0 deletions

65
pom.xml Normal file
View 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>

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

View 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
}

View file

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

View file

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

View 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

View file

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