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

Closed
forge-bot wants to merge 8 commits from forge/ACX-E2E-1785394564819-attempt-10-run-d30bf2e27f38 into main
8 changed files with 582 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>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>

View file

@ -0,0 +1,12 @@
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,44 @@
package com.acquirex.controller;
import com.acquirex.dto.AcquisitionDto.Request;
import com.acquirex.dto.AcquisitionDto.Response;
import com.acquirex.entity.CardAcquisition;
import com.acquirex.service.CardAcquisitionService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* 카드 매입 승인 REST API 컨트롤러
* Spring Boot 3: @RestController, @RequestMapping 유지
*/
@RestController
@RequestMapping("/api/v1/acquisitions")
public class CardAcquisitionController {
private final CardAcquisitionService acquisitionService;
public CardAcquisitionController(CardAcquisitionService acquisitionService) {
this.acquisitionService = acquisitionService;
}
@PostMapping
public ResponseEntity<Response> requestAcquisition(@Valid @RequestBody Request request) {
Response response = acquisitionService.processAcquisition(request);
if ("APPROVED".equals(response.getStatus())) {
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
@GetMapping("/{id}")
public ResponseEntity<CardAcquisition> getAcquisition(@PathVariable Long id) {
return ResponseEntity.ok(acquisitionService.findById(id));
}
@GetMapping("/approval/{approvalCode}")
public ResponseEntity<CardAcquisition> getByApprovalCode(@PathVariable String approvalCode) {
return ResponseEntity.ok(acquisitionService.findByApprovalCode(approvalCode));
}
}

View file

@ -0,0 +1,88 @@
package com.acquirex.dto;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 카드 매입 요청/응답 DTO
* Spring Boot 3: javax.validation jakarta.validation
*/
public class AcquisitionDto {
// ========== Request DTO ==========
public static class Request {
@NotBlank(message = "카드번호는 필수입니다")
@Size(min = 13, max = 19, message = "카드번호 길이가 올바르지 않습니다")
private String cardNumber;
@NotBlank(message = "가맹점ID는 필수입니다")
private String merchantId;
@NotNull(message = "금액은 필수입니다")
@DecimalMin(value = "0.01", message = "금액은 0보다 커야 합니다")
@Digits(integer = 13, fraction = 2, message = "금액 형식이 올바르지 않습니다")
private BigDecimal amount;
@NotBlank(message = "통화는 필수입니다")
@Size(min = 3, max = 3, message = "통화 코드는 3자리입니다")
private String currency;
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 String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
}
// ========== Response DTO ==========
public static class Response {
private Long id;
private String approvalCode;
private String status;
private String message;
private BigDecimal amount;
private String currency;
private LocalDateTime approvalTime;
public Response() {}
public static Response success(Long id, String approvalCode, BigDecimal amount, String currency) {
Response response = new Response();
response.setId(id);
response.setApprovalCode(approvalCode);
response.setStatus("APPROVED");
response.setMessage("매입 승인이 완료되었습니다");
response.setAmount(amount);
response.setCurrency(currency);
response.setApprovalTime(LocalDateTime.now());
return response;
}
public static Response rejected(Long id, String reason) {
Response response = new Response();
response.setId(id);
response.setStatus("REJECTED");
response.setMessage(reason);
return response;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getApprovalCode() { return approvalCode; }
public void setApprovalCode(String approvalCode) { this.approvalCode = approvalCode; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public LocalDateTime getApprovalTime() { return approvalTime; }
public void setApprovalTime(LocalDateTime approvalTime) { this.approvalTime = approvalTime; }
}
}

View file

@ -0,0 +1,89 @@
package com.acquirex.entity;
import jakarta.persistence.*;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 카드 매입 승인 엔티티 리포지토리
* Spring Boot 3: javax.persistence jakarta.persistence
*/
@Entity
@Table(name = "card_acquisitions")
public class CardAcquisition {
@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;
@Column(name = "currency", nullable = false, length = 3)
private String currency;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private AcquisitionStatus status;
@Column(name = "approval_code", length = 12)
private String approvalCode;
@Column(name = "request_time", nullable = false)
private LocalDateTime requestTime;
@Column(name = "approval_time")
private LocalDateTime approvalTime;
@Column(name = "reject_reason", length = 255)
private String rejectReason;
public enum AcquisitionStatus {
PENDING, APPROVED, REJECTED, CANCELLED
}
public CardAcquisition() {
this.requestTime = LocalDateTime.now();
this.status = AcquisitionStatus.PENDING;
}
// Getters and Setters
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 String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public AcquisitionStatus getStatus() { return status; }
public void setStatus(AcquisitionStatus status) { this.status = status; }
public String getApprovalCode() { return approvalCode; }
public void setApprovalCode(String approvalCode) { this.approvalCode = approvalCode; }
public LocalDateTime getRequestTime() { return requestTime; }
public void setRequestTime(LocalDateTime requestTime) { this.requestTime = requestTime; }
public LocalDateTime getApprovalTime() { return approvalTime; }
public void setApprovalTime(LocalDateTime approvalTime) { this.approvalTime = approvalTime; }
public String getRejectReason() { return rejectReason; }
public void setRejectReason(String rejectReason) { this.rejectReason = rejectReason; }
@Repository
public interface CardAcquisitionRepository extends JpaRepository<CardAcquisition, Long> {
List<CardAcquisition> findByStatus(AcquisitionStatus status);
Optional<CardAcquisition> findByApprovalCode(String approvalCode);
List<CardAcquisition> findByMerchantId(String merchantId);
}
}

View file

@ -0,0 +1,116 @@
package com.acquirex.service;
import com.acquirex.dto.AcquisitionDto.Request;
import com.acquirex.dto.AcquisitionDto.Response;
import com.acquirex.entity.CardAcquisition;
import com.acquirex.entity.CardAcquisition.AcquisitionStatus;
import com.acquirex.entity.CardAcquisition.CardAcquisitionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Random;
@Service
public class CardAcquisitionService {
private static final BigDecimal MAX_APPROVAL_AMOUNT = new BigDecimal("10000000");
private static final BigDecimal SUSPICIOUS_AMOUNT_THRESHOLD = new BigDecimal("5000000");
private final CardAcquisitionRepository repository;
private final Random approvalCodeGenerator = new Random();
public CardAcquisitionService(CardAcquisitionRepository repository) {
this.repository = repository;
}
@Transactional
public Response processAcquisition(Request request) {
CardAcquisition acquisition = createAcquisition(request);
CardAcquisition saved = repository.save(acquisition);
if (!validateCardNumber(request.getCardNumber())) {
return rejectAcquisition(saved, "유효하지 않은 카드번호입니다");
}
if (request.getAmount().compareTo(MAX_APPROVAL_AMOUNT) > 0) {
return rejectAcquisition(saved, "승인 한도를 초과했습니다");
}
if (isSuspiciousTransaction(request)) {
return rejectAcquisition(saved, "의심스러운 거래로 거부되었습니다");
}
return approveAcquisition(saved);
}
private CardAcquisition createAcquisition(Request request) {
CardAcquisition acquisition = new CardAcquisition();
acquisition.setCardNumber(maskCardNumber(request.getCardNumber()));
acquisition.setMerchantId(request.getMerchantId());
acquisition.setAmount(request.getAmount());
acquisition.setCurrency(request.getCurrency());
acquisition.setStatus(AcquisitionStatus.PENDING);
return acquisition;
}
private boolean validateCardNumber(String cardNumber) {
if (cardNumber == null || cardNumber.length() < 13) return false;
int sum = 0;
boolean alternate = false;
for (int i = cardNumber.length() - 1; i >= 0; i--) {
int digit = Character.getNumericValue(cardNumber.charAt(i));
if (alternate) {
digit *= 2;
if (digit > 9) digit -= 9;
}
sum += digit;
alternate = !alternate;
}
return sum % 10 == 0;
}
private boolean isSuspiciousTransaction(Request request) {
return request.getAmount().compareTo(SUSPICIOUS_AMOUNT_THRESHOLD) >= 0
&& request.getCardNumber().startsWith("9999");
}
private String generateApprovalCode() {
return String.format("%06d", approvalCodeGenerator.nextInt(999999));
}
private String maskCardNumber(String cardNumber) {
if (cardNumber == null || cardNumber.length() < 8) return cardNumber;
return cardNumber.substring(0, 4) + "****" + cardNumber.substring(cardNumber.length() - 4);
}
private Response approveAcquisition(CardAcquisition acquisition) {
String approvalCode = generateApprovalCode();
acquisition.setStatus(AcquisitionStatus.APPROVED);
acquisition.setApprovalCode(approvalCode);
acquisition.setApprovalTime(LocalDateTime.now());
repository.save(acquisition);
return Response.success(acquisition.getId(), approvalCode, acquisition.getAmount(), acquisition.getCurrency());
}
private Response rejectAcquisition(CardAcquisition acquisition, String reason) {
acquisition.setStatus(AcquisitionStatus.REJECTED);
acquisition.setRejectReason(reason);
acquisition.setApprovalTime(LocalDateTime.now());
repository.save(acquisition);
return Response.rejected(acquisition.getId(), reason);
}
@Transactional(readOnly = true)
public CardAcquisition findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("매입 정보를 찾을 수 없습니다: " + id));
}
@Transactional(readOnly = true)
public CardAcquisition findByApprovalCode(String approvalCode) {
return repository.findByApprovalCode(approvalCode)
.orElseThrow(() -> new IllegalArgumentException("승인코드를 찾을 수 없습니다: " + approvalCode));
}
}

View file

@ -0,0 +1,31 @@
spring:
application:
name: proj-acquirex
datasource:
url: jdbc:h2:mem:acquirexdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.H2Dialect
h2:
console:
enabled: true
path: /h2-console
server:
port: 8080
logging:
level:
com.acquirex: DEBUG
org.hibernate.SQL: DEBUG

View file

@ -0,0 +1,145 @@
package com.acquirex.service;
import com.acquirex.dto.AcquisitionDto.Request;
import com.acquirex.dto.AcquisitionDto.Response;
import com.acquirex.entity.CardAcquisition;
import com.acquirex.entity.CardAcquisition.CardAcquisitionRepository;
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 static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CardAcquisitionServiceTest {
@Mock
private CardAcquisitionRepository repository;
private CardAcquisitionService service;
@BeforeEach
void setUp() {
service = new CardAcquisitionService(repository);
}
@Test
@DisplayName("유효한 카드로 정상 승인 처리")
void processAcquisition_ValidCard_ShouldApprove() {
Request request = new Request();
request.setCardNumber("4532015112830366");
request.setMerchantId("MERCHANT001");
request.setAmount(new BigDecimal("100000"));
request.setCurrency("KRW");
when(repository.save(any(CardAcquisition.class)))
.thenAnswer(invocation -> {
CardAcquisition acq = invocation.getArgument(0);
acq.setId(1L);
return acq;
});
Response response = service.processAcquisition(request);
assertEquals("APPROVED", response.getStatus());
assertNotNull(response.getApprovalCode());
assertEquals(6, response.getApprovalCode().length());
assertEquals(new BigDecimal("100000"), response.getAmount());
assertEquals("KRW", response.getCurrency());
}
@Test
@DisplayName("유효하지 않은 카드번호 거부")
void processAcquisition_InvalidCardNumber_ShouldReject() {
Request request = new Request();
request.setCardNumber("1234567890123456");
request.setMerchantId("MERCHANT001");
request.setAmount(new BigDecimal("50000"));
request.setCurrency("KRW");
when(repository.save(any(CardAcquisition.class)))
.thenAnswer(invocation -> {
CardAcquisition acq = invocation.getArgument(0);
acq.setId(1L);
return acq;
});
Response response = service.processAcquisition(request);
assertEquals("REJECTED", response.getStatus());
assertNull(response.getApprovalCode());
assertTrue(response.getMessage().contains("유효하지 않은"));
}
@Test
@DisplayName("한도 초과 거래 거부")
void processAcquisition_ExceedLimit_ShouldReject() {
Request request = new Request();
request.setCardNumber("4532015112830366");
request.setMerchantId("MERCHANT001");
request.setAmount(new BigDecimal("15000000"));
request.setCurrency("KRW");
when(repository.save(any(CardAcquisition.class)))
.thenAnswer(invocation -> {
CardAcquisition acq = invocation.getArgument(0);
acq.setId(1L);
return acq;
});
Response response = service.processAcquisition(request);
assertEquals("REJECTED", response.getStatus());
assertTrue(response.getMessage().contains("한도"));
}
@Test
@DisplayName("의심스러운 거래 거부 (9999 시작 + 고액)")
void processAcquisition_SuspiciousTransaction_ShouldReject() {
Request request = new Request();
request.setCardNumber("9999123456789012");
request.setMerchantId("MERCHANT001");
request.setAmount(new BigDecimal("6000000"));
request.setCurrency("KRW");
when(repository.save(any(CardAcquisition.class)))
.thenAnswer(invocation -> {
CardAcquisition acq = invocation.getArgument(0);
acq.setId(1L);
return acq;
});
Response response = service.processAcquisition(request);
assertEquals("REJECTED", response.getStatus());
assertTrue(response.getMessage().contains("의심스러운"));
}
@Test
@DisplayName("카드번호 마스킹 처리 확인")
void processAcquisition_CardNumberMasked() {
Request request = new Request();
request.setCardNumber("4532015112830366");
request.setMerchantId("MERCHANT001");
request.setAmount(new BigDecimal("100000"));
request.setCurrency("KRW");
when(repository.save(any(CardAcquisition.class)))
.thenAnswer(invocation -> {
CardAcquisition acq = invocation.getArgument(0);
acq.setId(1L);
return acq;
});
service.processAcquisition(request);
verify(repository, times(2)).save(argThat(acq -> acq.getCardNumber().equals("4532****0366")));
}
}