카드 매입 승인 경로 Spring Boot 3 전환 #6
8 changed files with 513 additions and 0 deletions
66
pom.xml
Normal file
66
pom.xml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?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 http://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>
|
||||
<packaging>jar</packaging>
|
||||
<name>proj-acquirex</name>
|
||||
<description>Card Acquisition Approval System - Spring Boot 3</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</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>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.5</version>
|
||||
</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,38 @@
|
|||
package com.acquirex.approval.controller;
|
||||
|
||||
import com.acquirex.approval.dto.ApprovalDto.Request;
|
||||
import com.acquirex.approval.dto.ApprovalDto.Response;
|
||||
import com.acquirex.approval.service.ApprovalService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/approvals")
|
||||
public class ApprovalController {
|
||||
|
||||
private final ApprovalService approvalService;
|
||||
|
||||
public ApprovalController(ApprovalService approvalService) {
|
||||
this.approvalService = approvalService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Response> submitApproval(@Valid @RequestBody Request request) {
|
||||
Response response = approvalService.processApproval(request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<Response> getApproval(@PathVariable Long id) {
|
||||
var request = approvalService.findById(id);
|
||||
Response response = Response.approved(request.getId(), request.getApprovalCode(), request.getAmount(), request.getCurrency());
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/stats/pending")
|
||||
public ResponseEntity<Long> getPendingCount() {
|
||||
return ResponseEntity.ok(approvalService.countPendingApprovals());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package com.acquirex.approval.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "approval_requests")
|
||||
public class ApprovalRequest {
|
||||
|
||||
public enum ApprovalStatus {
|
||||
PENDING, APPROVED, DECLINED, CANCELLED, ERROR
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@NotBlank
|
||||
@Column(name = "card_number", length = 19)
|
||||
private String cardNumber;
|
||||
|
||||
@NotBlank
|
||||
@Column(name = "merchant_id", length = 15)
|
||||
private String merchantId;
|
||||
|
||||
@NotNull
|
||||
@Positive
|
||||
@Column(name = "amount", precision = 15, scale = 2)
|
||||
private BigDecimal amount;
|
||||
|
||||
@NotBlank
|
||||
@Column(name = "currency", length = 3)
|
||||
private String currency;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", length = 20)
|
||||
private ApprovalStatus status;
|
||||
|
||||
@Column(name = "approval_code", length = 10)
|
||||
private String approvalCode;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public ApprovalRequest() {}
|
||||
|
||||
public ApprovalRequest(String cardNumber, String merchantId, BigDecimal amount, String currency) {
|
||||
this.cardNumber = cardNumber;
|
||||
this.merchantId = merchantId;
|
||||
this.amount = amount;
|
||||
this.currency = currency;
|
||||
this.status = ApprovalStatus.PENDING;
|
||||
this.createdAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@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 String getCurrency() { return currency; }
|
||||
public void setCurrency(String currency) { this.currency = currency; }
|
||||
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; }
|
||||
}
|
||||
102
src/main/java/com/acquirex/approval/dto/ApprovalDto.java
Normal file
102
src/main/java/com/acquirex/approval/dto/ApprovalDto.java
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package com.acquirex.approval.dto;
|
||||
|
||||
import com.acquirex.approval.domain.ApprovalRequest.ApprovalStatus;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public class ApprovalDto {
|
||||
|
||||
public static class Request {
|
||||
@NotBlank(message = "Card number is required")
|
||||
@Pattern(regexp = "^\\d{13,19}$", message = "Invalid card number format")
|
||||
private String cardNumber;
|
||||
|
||||
@NotBlank(message = "Merchant ID is required")
|
||||
@Pattern(regexp = "^[A-Z0-9]{8,15}$", message = "Invalid merchant ID format")
|
||||
private String merchantId;
|
||||
|
||||
@NotNull(message = "Amount is required")
|
||||
@Positive(message = "Amount must be positive")
|
||||
private BigDecimal amount;
|
||||
|
||||
@NotBlank(message = "Currency is required")
|
||||
@Pattern(regexp = "^[A-Z]{3}$", message = "Invalid currency code")
|
||||
private String currency;
|
||||
|
||||
public Request() {}
|
||||
|
||||
public Request(String cardNumber, String merchantId, BigDecimal amount, String currency) {
|
||||
this.cardNumber = cardNumber;
|
||||
this.merchantId = merchantId;
|
||||
this.amount = amount;
|
||||
this.currency = 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; }
|
||||
}
|
||||
|
||||
public static class Response {
|
||||
private Long requestId;
|
||||
private String approvalCode;
|
||||
private ApprovalStatus status;
|
||||
private BigDecimal amount;
|
||||
private String currency;
|
||||
private String message;
|
||||
private LocalDateTime timestamp;
|
||||
|
||||
public Response() {
|
||||
this.timestamp = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public Response(Long requestId, ApprovalStatus status, String message) {
|
||||
this.requestId = requestId;
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
this.timestamp = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public static Response approved(Long requestId, String approvalCode, BigDecimal amount, String currency) {
|
||||
Response r = new Response();
|
||||
r.requestId = requestId;
|
||||
r.approvalCode = approvalCode;
|
||||
r.status = ApprovalStatus.APPROVED;
|
||||
r.amount = amount;
|
||||
r.currency = currency;
|
||||
r.message = "Approval successful";
|
||||
return r;
|
||||
}
|
||||
|
||||
public static Response declined(Long requestId, String reason) {
|
||||
Response r = new Response();
|
||||
r.requestId = requestId;
|
||||
r.status = ApprovalStatus.DECLINED;
|
||||
r.message = reason;
|
||||
return r;
|
||||
}
|
||||
|
||||
public Long getRequestId() { return requestId; }
|
||||
public void setRequestId(Long requestId) { this.requestId = requestId; }
|
||||
public String getApprovalCode() { return approvalCode; }
|
||||
public void setApprovalCode(String approvalCode) { this.approvalCode = approvalCode; }
|
||||
public ApprovalStatus getStatus() { return status; }
|
||||
public void setStatus(ApprovalStatus status) { this.status = status; }
|
||||
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 String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
public LocalDateTime getTimestamp() { return timestamp; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.acquirex.approval.repository;
|
||||
|
||||
import com.acquirex.approval.domain.ApprovalRequest;
|
||||
import com.acquirex.approval.domain.ApprovalRequest.ApprovalStatus;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface ApprovalRequestRepository extends JpaRepository<ApprovalRequest, Long> {
|
||||
|
||||
List<ApprovalRequest> findByStatus(ApprovalStatus status);
|
||||
|
||||
List<ApprovalRequest> findByMerchantId(String merchantId);
|
||||
|
||||
List<ApprovalRequest> findByCardNumberAndCreatedAtAfter(String cardNumber, LocalDateTime after);
|
||||
|
||||
long countByStatus(ApprovalStatus status);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package com.acquirex.approval.service;
|
||||
|
||||
import com.acquirex.approval.domain.ApprovalRequest;
|
||||
import com.acquirex.approval.domain.ApprovalRequest.ApprovalStatus;
|
||||
import com.acquirex.approval.dto.ApprovalDto.Request;
|
||||
import com.acquirex.approval.dto.ApprovalDto.Response;
|
||||
import com.acquirex.approval.repository.ApprovalRequestRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class ApprovalService {
|
||||
|
||||
private final ApprovalRequestRepository repository;
|
||||
|
||||
public ApprovalService(ApprovalRequestRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Response processApproval(Request requestDto) {
|
||||
ApprovalRequest request = new ApprovalRequest(
|
||||
requestDto.getCardNumber(),
|
||||
requestDto.getMerchantId(),
|
||||
requestDto.getAmount(),
|
||||
requestDto.getCurrency()
|
||||
);
|
||||
|
||||
ApprovalRequest saved = repository.save(request);
|
||||
|
||||
if (validateRequest(requestDto)) {
|
||||
String approvalCode = generateApprovalCode();
|
||||
saved.setStatus(ApprovalStatus.APPROVED);
|
||||
saved.setApprovalCode(approvalCode);
|
||||
repository.save(saved);
|
||||
return Response.approved(saved.getId(), approvalCode, saved.getAmount(), saved.getCurrency());
|
||||
} else {
|
||||
saved.setStatus(ApprovalStatus.DECLINED);
|
||||
repository.save(saved);
|
||||
return Response.declined(saved.getId(), "Validation failed");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ApprovalRequest findById(Long id) {
|
||||
return repository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Approval request not found: " + id));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long countPendingApprovals() {
|
||||
return repository.countByStatus(ApprovalStatus.PENDING);
|
||||
}
|
||||
|
||||
private boolean validateRequest(Request dto) {
|
||||
if (dto.getAmount() == null || dto.getAmount().signum() <= 0) return false;
|
||||
if (dto.getCardNumber() == null || dto.getCardNumber().length() < 13) return false;
|
||||
if (dto.getMerchantId() == null || dto.getMerchantId().isEmpty()) return false;
|
||||
return dto.getCurrency() != null && dto.getCurrency().length() == 3;
|
||||
}
|
||||
|
||||
private String generateApprovalCode() {
|
||||
return "APP-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
package com.acquirex.approval.service;
|
||||
|
||||
import com.acquirex.approval.domain.ApprovalRequest;
|
||||
import com.acquirex.approval.domain.ApprovalRequest.ApprovalStatus;
|
||||
import com.acquirex.approval.dto.ApprovalDto.Request;
|
||||
import com.acquirex.approval.dto.ApprovalDto.Response;
|
||||
import com.acquirex.approval.repository.ApprovalRequestRepository;
|
||||
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 ApprovalServiceTest {
|
||||
|
||||
@Mock
|
||||
private ApprovalRequestRepository repository;
|
||||
|
||||
private ApprovalService approvalService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
approvalService = new ApprovalService(repository);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should approve valid request")
|
||||
void processApproval_ValidRequest_ReturnsApproved() {
|
||||
Request dto = new Request("4532123456789012", "MERCHANT001", new BigDecimal("100.00"), "KRW");
|
||||
|
||||
ApprovalRequest savedRequest = new ApprovalRequest(dto.getCardNumber(), dto.getMerchantId(), dto.getAmount(), dto.getCurrency());
|
||||
savedRequest.setId(1L);
|
||||
|
||||
when(repository.save(any(ApprovalRequest.class))).thenReturn(savedRequest);
|
||||
|
||||
Response response = approvalService.processApproval(dto);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(ApprovalStatus.APPROVED, response.getStatus());
|
||||
assertNotNull(response.getApprovalCode());
|
||||
assertTrue(response.getApprovalCode().startsWith("APP-"));
|
||||
verify(repository, times(2)).save(any(ApprovalRequest.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should decline request with invalid amount")
|
||||
void processApproval_InvalidAmount_ReturnsDeclined() {
|
||||
Request dto = new Request("4532123456789012", "MERCHANT001", new BigDecimal("-10.00"), "KRW");
|
||||
|
||||
ApprovalRequest savedRequest = new ApprovalRequest(dto.getCardNumber(), dto.getMerchantId(), dto.getAmount(), dto.getCurrency());
|
||||
savedRequest.setId(1L);
|
||||
|
||||
when(repository.save(any(ApprovalRequest.class))).thenReturn(savedRequest);
|
||||
|
||||
Response response = approvalService.processApproval(dto);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(ApprovalStatus.DECLINED, response.getStatus());
|
||||
assertEquals("Validation failed", response.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should decline request with short card number")
|
||||
void processApproval_ShortCardNumber_ReturnsDeclined() {
|
||||
Request dto = new Request("1234", "MERCHANT001", new BigDecimal("100.00"), "KRW");
|
||||
|
||||
ApprovalRequest savedRequest = new ApprovalRequest(dto.getCardNumber(), dto.getMerchantId(), dto.getAmount(), dto.getCurrency());
|
||||
savedRequest.setId(1L);
|
||||
|
||||
when(repository.save(any(ApprovalRequest.class))).thenReturn(savedRequest);
|
||||
|
||||
Response response = approvalService.processApproval(dto);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(ApprovalStatus.DECLINED, response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should find approval by ID")
|
||||
void findById_ExistingId_ReturnsRequest() {
|
||||
ApprovalRequest request = new ApprovalRequest("4532123456789012", "MERCHANT001", new BigDecimal("100.00"), "KRW");
|
||||
request.setId(1L);
|
||||
request.setStatus(ApprovalStatus.APPROVED);
|
||||
|
||||
when(repository.findById(1L)).thenReturn(java.util.Optional.of(request));
|
||||
|
||||
ApprovalRequest result = approvalService.findById(1L);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1L, result.getId());
|
||||
assertEquals(ApprovalStatus.APPROVED, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for non-existing ID")
|
||||
void findById_NonExistingId_ThrowsException() {
|
||||
when(repository.findById(999L)).thenReturn(java.util.Optional.empty());
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> approvalService.findById(999L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should count pending approvals")
|
||||
void countPendingApprovals_ReturnsCorrectCount() {
|
||||
when(repository.countByStatus(ApprovalStatus.PENDING)).thenReturn(5L);
|
||||
|
||||
long count = approvalService.countPendingApprovals();
|
||||
|
||||
assertEquals(5L, count);
|
||||
verify(repository).countByStatus(ApprovalStatus.PENDING);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue