diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..cbac232
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,57 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.5
+
+
+
+ com.acquirex
+ proj-acquirex
+ 1.0.0-SNAPSHOT
+ proj-acquirex
+ 카드 매입 승인 시스템 - Spring Boot 3
+
+
+ 17
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ com.h2database
+ h2
+ runtime
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/acquirex/AcquirexApplication.java b/src/main/java/com/acquirex/AcquirexApplication.java
new file mode 100644
index 0000000..dedeed3
--- /dev/null
+++ b/src/main/java/com/acquirex/AcquirexApplication.java
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/acquirex/controller/CardAcquisitionController.java b/src/main/java/com/acquirex/controller/CardAcquisitionController.java
new file mode 100644
index 0000000..92eec72
--- /dev/null
+++ b/src/main/java/com/acquirex/controller/CardAcquisitionController.java
@@ -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 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 getAcquisition(@PathVariable Long id) {
+ return ResponseEntity.ok(acquisitionService.findById(id));
+ }
+
+ @GetMapping("/approval/{approvalCode}")
+ public ResponseEntity getByApprovalCode(@PathVariable String approvalCode) {
+ return ResponseEntity.ok(acquisitionService.findByApprovalCode(approvalCode));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/acquirex/dto/AcquisitionDto.java b/src/main/java/com/acquirex/dto/AcquisitionDto.java
new file mode 100644
index 0000000..4cf27a8
--- /dev/null
+++ b/src/main/java/com/acquirex/dto/AcquisitionDto.java
@@ -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; }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/acquirex/entity/CardAcquisition.java b/src/main/java/com/acquirex/entity/CardAcquisition.java
new file mode 100644
index 0000000..c3e8507
--- /dev/null
+++ b/src/main/java/com/acquirex/entity/CardAcquisition.java
@@ -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 {
+ List findByStatus(AcquisitionStatus status);
+ Optional findByApprovalCode(String approvalCode);
+ List findByMerchantId(String merchantId);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/acquirex/service/CardAcquisitionService.java b/src/main/java/com/acquirex/service/CardAcquisitionService.java
new file mode 100644
index 0000000..0d8493f
--- /dev/null
+++ b/src/main/java/com/acquirex/service/CardAcquisitionService.java
@@ -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));
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..643ac40
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -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
\ No newline at end of file
diff --git a/src/test/java/com/acquirex/service/CardAcquisitionServiceTest.java b/src/test/java/com/acquirex/service/CardAcquisitionServiceTest.java
new file mode 100644
index 0000000..b77412e
--- /dev/null
+++ b/src/test/java/com/acquirex/service/CardAcquisitionServiceTest.java
@@ -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")));
+ }
+}
\ No newline at end of file