Compare commits

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

8 commits

Author SHA1 Message Date
793c7f10ad 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
All checks were successful
Verify / source-contract (push) Successful in 11s
Verify / source-contract (pull_request) Successful in 13s
2026-07-30 10:04:31 +00:00
fda9764f5c 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks failed
Verify / source-contract (push) Has been cancelled
2026-07-30 10:04:30 +00:00
7f2b733f2f 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 10:04:29 +00:00
3f27fd49b7 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 10:04:27 +00:00
a116697217 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks failed
Verify / source-contract (push) Has been cancelled
2026-07-30 10:04:26 +00:00
52468c0679 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 10:04:24 +00:00
ed60901b36 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 10:04:23 +00:00
6c76f600a6 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks failed
Verify / source-contract (push) Has been cancelled
2026-07-30 10:04:22 +00:00
8 changed files with 513 additions and 0 deletions

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

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

View file

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

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

View file

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

View file

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

View file

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