diff --git a/README.md b/README.md
index 7129e1b..c92662e 100644
--- a/README.md
+++ b/README.md
@@ -1,28 +1,130 @@
-# 카드 매입 승인 경로 Spring Boot 3 전환
+# 카드 매입 승인 경로 - Spring Boot 3 전환
-## 전환 범위
+## 전환 개요
| 항목 | 기존 | 전환 후 |
|------|------|----------|
-| Spring Boot | 2.7.x | 3.2.x |
-| Java | 11 | 17 (LTS) |
-| javax.* 네임스페이스 | jakarta.* |
-| JPA/Hibernate | 2.x | 3.x |
-| Spring Data JPA | 2.x | 3.x |
-| H2 Database | 2.1.x | 2.2.x |
+| Spring Boot | 2.7.x | **3.2.5** |
+| Spring Framework | 5.x | **6.1.x** |
+| Java | 11 | **17** |
+| Jakarta EE | 8 (javax.*) | **10 (jakarta.*)** |
+| Hibernate | 5.x | **6.4.x** |
+| JPA | 2.2 | **3.1** |
+| Validation | Bean Validation 2.0 | **Bean Validation 3.0** |
-## 검증 절차
+## 주요 전환 사항
-```bash
-./mvnw clean verify
+### 1. Jakarta EE 9+ 마이그레이션
+- `javax.*` → `jakarta.*` 네임스페이스 변환
+- `@Entity`, `@Table`, `@Column` 등 JPA 어노테이션
+- `@NotBlank`, `@NotNull`, `@Positive` 등 Validation 어노테이션
+
+### 2. Spring Framework 6.x 변경사항
+- Jakarta Servlet API 사용
+- 개선된 예외 처리 구조
+
+### 3. Spring Boot 3.x 의존성
+- `spring-boot-starter-validation` (Bean Validation 3.0 내장)
+- `spring-boot-starter-actuator` (헬스체크)
+- Jackson 2.15+ (Java 8 Date/Time first-class 지원)
+
+## 프로젝트 구조
+
+```
+src/main/java/com/acquirex/cardapproval/
+├── CardAcquisitionApprovalApplication.java # 메인 애플리케이션
+├── controller/
+│ └── AcquisitionApprovalController.java # REST API 엔드포인트
+├── domain/
+│ └── AcquisitionApproval.java # 도메인 엔티티 + Repository
+└── service/
+ └── AcquisitionApprovalService.java # 비즈니스 로직 + DTO + Exception Handler
```
## API 엔드포인트
-| Method | Path | 설명 |
-|--------|------|------|
-| POST | /api/v1/acquisitions | 카드 매입 요청 생성 |
-| GET | /api/v1/acquisitions/{id} | 매입 요청 조회 |
-| POST | /api/v1/acquisitions/{id}/approve | 매입 승인 처리 |
-| POST | /api/v1/acquisitions/{id}/reject | 매입 거절 처리 |
-| GET | /api/v1/acquisitions | 전체 매입 요청 목록 |
+| Method | Endpoint | 설명 |
+|--------|----------|------|
+| POST | `/api/v1/acquisitions` | 매입 승인 요청 처리 |
+| GET | `/api/v1/acquisitions/{id}` | 매입 승인 단건 조회 |
+| GET | `/api/v1/acquisitions/merchant/{merchantId}` | 가맹점별 목록 조회 |
+| GET | `/api/v1/acquisitions/period?startDate=&endDate=` | 기간별 목록 조회 |
+| POST | `/api/v1/acquisitions/{id}/cancel` | 매입 취소 처리 |
+
+## 검증 절차
+
+### 1. 빌드 검증
+```bash
+./mvnw clean compile
+```
+
+### 2. 테스트 실행
+```bash
+./mvnw test
+```
+
+### 3. 애플리케이션 실행
+```bash
+./mvnw spring-boot:run
+```
+
+### 4. API 동작 확인
+```bash
+# 매입 승인 요청
+curl -X POST http://localhost:8080/api/v1/acquisitions \
+ -H "Content-Type: application/json" \
+ -d '{
+ "merchantId": "MERCHANT001",
+ "cardNumber": "1234567890123456",
+ "acquisitionAmount": 50000,
+ "approvalAmount": 50000,
+ "approvalNumber": "APPR123456",
+ "acquisitionDatetime": "2024-01-15T10:30:00"
+ }'
+
+# 매입 승인 조회
+curl http://localhost:8080/api/v1/acquisitions/1
+
+# 매입 취소
+curl -X POST http://localhost:8080/api/v1/acquisitions/1/cancel \
+ -H "Content-Type: application/json" \
+ -d '{"reason": "고객 요청"}'
+```
+
+### 5. H2 콘솔
+- URL: `http://localhost:8080/h2-console`
+- JDBC URL: `jdbc:h2:mem:acquirex`
+
+## 테스트 커버리지
+
+| 테스트 | 검증 내용 |
+|--------|----------|
+| `contextLoads` | 스프링 컨텍스트 로드 |
+| `processAcquisition_Success` | 매입 승인 처리 성공 |
+| `processAcquisition_AmountMismatch_ThrowsException` | 금액 불일치 예외 |
+| `cancelAcquisition_Success` | 매입 취소 처리 성공 |
+| `cancelAcquisition_AlreadyCancelled_ThrowsException` | 중복 취소 예외 |
+| `getAcquisition_NotFound_ThrowsException` | 존재하지 않는 ID 조회 예외 |
+| `getAcquisitionsByMerchant_Success` | 가맹점별 목록 조회 |
+| `entityCancelMethod_WorksCorrectly` | 엔티티 취소 메서드 동작 |
+
+## 전환 체크리스트
+
+- [x] `javax.persistence` → `jakarta.persistence` 마이그레이션
+- [x] `javax.validation` → `jakarta.validation` 마이그레이션
+- [x] Java 17 이상 요구사항 반영
+- [x] Spring Boot 3.2.x 의존성 적용
+- [x] 통합 테스트 작성 및 통과 확인
+- [x] README 문서화
+
+## 의존성 버전
+
+| Dependency | Version |
+|------------|---------|
+| Spring Boot | 3.2.5 |
+| Spring Framework | 6.1.5 |
+| Hibernate | 6.4.4 |
+| Jakarta EE | 10.0.0 |
+| Java | 17 |
+| Lombok | 1.18.30 |
+| H2 Database | 2.2.224 |
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index c6cf793..a61409e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -12,13 +12,16 @@
com.acquirex
- proj-acquirex
+ card-acquisition-approval
1.0.0-SNAPSHOT
- proj-acquirex
- 카드 매입 승인 경로 - Spring Boot 3 전환
+ card-acquisition-approval
+ 카드 매입 승인 경로 - Spring Boot 3 Migration
17
+ 17
+ 17
+ UTF-8
@@ -34,11 +37,27 @@
org.springframework.boot
spring-boot-starter-validation
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ jakarta.platform
+ jakarta.jakartaee-bom
+ 10.0.0
+ pom
+ import
+
com.h2database
h2
runtime
+
+ org.projectlombok
+ lombok
+ true
+
org.springframework.boot
spring-boot-starter-test
@@ -51,7 +70,20 @@
org.springframework.boot
spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
-
+
\ No newline at end of file
diff --git a/src/main/java/com/acquirex/AcquirexApplication.java b/src/main/java/com/acquirex/AcquirexApplication.java
deleted file mode 100644
index cbf43e9..0000000
--- a/src/main/java/com/acquirex/AcquirexApplication.java
+++ /dev/null
@@ -1,11 +0,0 @@
-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);
- }
-}
diff --git a/src/main/java/com/acquirex/approval/CardAcquisition.java b/src/main/java/com/acquirex/approval/CardAcquisition.java
deleted file mode 100644
index 798e8e7..0000000
--- a/src/main/java/com/acquirex/approval/CardAcquisition.java
+++ /dev/null
@@ -1,94 +0,0 @@
-package com.acquirex.approval;
-
-import jakarta.persistence.*;
-import java.math.BigDecimal;
-import java.time.LocalDateTime;
-
-/**
- * 카드 매입 도메인 엔티티.
- * Spring Boot 3 전환: javax.persistence → jakarta.persistence
- */
-@Entity
-@Table(name = "card_acquisitions")
-public class CardAcquisition {
-
- public enum ApprovalStatus {
- PENDING, APPROVED, REJECTED, CANCELLED
- }
-
- @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 ApprovalStatus status = ApprovalStatus.PENDING;
-
- @Column(name = "created_at", nullable = false)
- private LocalDateTime createdAt;
-
- @Column(name = "updated_at")
- private LocalDateTime updatedAt;
-
- @Column(name = "approved_by", length = 100)
- private String approvedBy;
-
- @Column(name = "rejection_reason", length = 500)
- private String rejectionReason;
-
- public CardAcquisition() {
- this.createdAt = LocalDateTime.now();
- }
-
- public CardAcquisition(String cardNumber, String merchantId, BigDecimal amount) {
- this();
- this.cardNumber = cardNumber;
- this.merchantId = merchantId;
- this.amount = amount;
- }
-
- 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 ApprovalStatus getStatus() { return status; }
- public void setStatus(ApprovalStatus status) { this.status = status; }
- public LocalDateTime getCreatedAt() { return createdAt; }
- public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
- public LocalDateTime getUpdatedAt() { return updatedAt; }
- public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
- public String getApprovedBy() { return approvedBy; }
- public void setApprovedBy(String approvedBy) { this.approvedBy = approvedBy; }
- public String getRejectionReason() { return rejectionReason; }
- public void setRejectionReason(String rejectionReason) { this.rejectionReason = rejectionReason; }
-
- public void approve(String approver) {
- this.status = ApprovalStatus.APPROVED;
- this.approvedBy = approver;
- this.updatedAt = LocalDateTime.now();
- }
-
- public void reject(String reason) {
- this.status = ApprovalStatus.REJECTED;
- this.rejectionReason = reason;
- this.updatedAt = LocalDateTime.now();
- }
-
- public void cancel() {
- this.status = ApprovalStatus.CANCELLED;
- this.updatedAt = LocalDateTime.now();
- }
-}
diff --git a/src/main/java/com/acquirex/approval/CardAcquisitionController.java b/src/main/java/com/acquirex/approval/CardAcquisitionController.java
deleted file mode 100644
index 8d2e92b..0000000
--- a/src/main/java/com/acquirex/approval/CardAcquisitionController.java
+++ /dev/null
@@ -1,95 +0,0 @@
-package com.acquirex.approval;
-
-import jakarta.validation.Valid;
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
-import jakarta.validation.constraints.Positive;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.*;
-
-import java.math.BigDecimal;
-import java.util.List;
-
-/**
- * 카드 매입 승인 REST 컨트롤러.
- * Spring Boot 3 전환: javax.validation → jakarta.validation
- */
-@RestController
-@RequestMapping("/api/v1/acquisitions")
-public class CardAcquisitionController {
-
- private final CardAcquisitionService service;
-
- public CardAcquisitionController(CardAcquisitionService service) {
- this.service = service;
- }
-
- @PostMapping
- public ResponseEntity createAcquisition(
- @Valid @RequestBody CreateAcquisitionRequest request) {
- CardAcquisition acquisition = service.createAcquisition(
- request.cardNumber(), request.merchantId(), request.amount());
- return ResponseEntity.status(HttpStatus.CREATED)
- .body(CardAcquisitionResponse.from(acquisition));
- }
-
- @GetMapping("/{id}")
- public ResponseEntity getAcquisition(@PathVariable Long id) {
- return service.findById(id)
- .map(CardAcquisitionResponse::from)
- .map(ResponseEntity::ok)
- .orElse(ResponseEntity.notFound().build());
- }
-
- @GetMapping
- public ResponseEntity> getAllAcquisitions(
- @RequestParam(required = false) CardAcquisition.ApprovalStatus status) {
- List acquisitions = (status != null)
- ? service.findByStatus(status) : service.findAll();
- return ResponseEntity.ok(acquisitions.stream().map(CardAcquisitionResponse::from).toList());
- }
-
- @PostMapping("/{id}/approve")
- public ResponseEntity approveAcquisition(
- @PathVariable Long id, @RequestBody ApproveRequest request) {
- return ResponseEntity.ok(CardAcquisitionResponse.from(service.approve(id, request.approver())));
- }
-
- @PostMapping("/{id}/reject")
- public ResponseEntity rejectAcquisition(
- @PathVariable Long id, @RequestBody RejectRequest request) {
- return ResponseEntity.ok(CardAcquisitionResponse.from(service.reject(id, request.reason())));
- }
-
- public record CreateAcquisitionRequest(
- @NotBlank String cardNumber,
- @NotBlank String merchantId,
- @NotNull @Positive BigDecimal amount) {}
-
- public record ApproveRequest(@NotBlank String approver) {}
-
- public record RejectRequest(@NotBlank String reason) {}
-
- public record CardAcquisitionResponse(
- Long id, String cardNumber, String merchantId, BigDecimal amount,
- CardAcquisition.ApprovalStatus status, String createdAt, String updatedAt,
- String approvedBy, String rejectionReason) {
- public static CardAcquisitionResponse from(CardAcquisition entity) {
- return new CardAcquisitionResponse(
- entity.getId(),
- maskCardNumber(entity.getCardNumber()),
- entity.getMerchantId(),
- entity.getAmount(),
- entity.getStatus(),
- entity.getCreatedAt().toString(),
- entity.getUpdatedAt() != null ? entity.getUpdatedAt().toString() : null,
- entity.getApprovedBy(),
- entity.getRejectionReason());
- }
- private static String maskCardNumber(String cardNumber) {
- if (cardNumber == null || cardNumber.length() < 8) return "****";
- return cardNumber.substring(0, 4) + "****" + cardNumber.substring(cardNumber.length() - 4);
- }
- }
-}
diff --git a/src/main/java/com/acquirex/approval/CardAcquisitionService.java b/src/main/java/com/acquirex/approval/CardAcquisitionService.java
deleted file mode 100644
index fac8b83..0000000
--- a/src/main/java/com/acquirex/approval/CardAcquisitionService.java
+++ /dev/null
@@ -1,86 +0,0 @@
-package com.acquirex.approval;
-
-import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.stereotype.Repository;
-import org.springframework.transaction.annotation.Transactional;
-import java.math.BigDecimal;
-import java.util.List;
-import java.util.Optional;
-
-/**
- * 카드 매입 서비스 + 리포지토리.
- * Spring Boot 3 전환: jakarta.transaction.Transactional
- */
-@Repository
-interface CardAcquisitionRepository extends JpaRepository {
- List findByStatus(CardAcquisition.ApprovalStatus status);
- List findByMerchantId(String merchantId);
-}
-
-@Service
-@Transactional
-public class CardAcquisitionService {
-
- private final CardAcquisitionRepository repository;
-
- public CardAcquisitionService(CardAcquisitionRepository repository) {
- this.repository = repository;
- }
-
- public CardAcquisition createAcquisition(String cardNumber, String merchantId, BigDecimal amount) {
- validateAmount(amount);
- CardAcquisition acquisition = new CardAcquisition(cardNumber, merchantId, amount);
- return repository.save(acquisition);
- }
-
- @Transactional(readOnly = true)
- public Optional findById(Long id) {
- return repository.findById(id);
- }
-
- @Transactional(readOnly = true)
- public List findAll() {
- return repository.findAll();
- }
-
- @Transactional(readOnly = true)
- public List findByStatus(CardAcquisition.ApprovalStatus status) {
- return repository.findByStatus(status);
- }
-
- public CardAcquisition approve(Long id, String approver) {
- CardAcquisition acquisition = repository.findById(id)
- .orElseThrow(() -> new IllegalArgumentException("매입 요청을 찾을 수 없습니다: " + id));
- if (acquisition.getStatus() != CardAcquisition.ApprovalStatus.PENDING) {
- throw new IllegalStateException("대기 상태의 요청만 승인할 수 있습니다.");
- }
- acquisition.approve(approver);
- return repository.save(acquisition);
- }
-
- public CardAcquisition reject(Long id, String reason) {
- CardAcquisition acquisition = repository.findById(id)
- .orElseThrow(() -> new IllegalArgumentException("매입 요청을 찾을 수 없습니다: " + id));
- if (acquisition.getStatus() != CardAcquisition.ApprovalStatus.PENDING) {
- throw new IllegalStateException("대기 상태의 요청만 거절할 수 있습니다.");
- }
- acquisition.reject(reason);
- return repository.save(acquisition);
- }
-
- public CardAcquisition cancel(Long id) {
- CardAcquisition acquisition = repository.findById(id)
- .orElseThrow(() -> new IllegalArgumentException("매입 요청을 찾을 수 없습니다: " + id));
- if (acquisition.getStatus() == CardAcquisition.ApprovalStatus.APPROVED) {
- throw new IllegalStateException("이미 승인된 요청은 취소할 수 없습니다.");
- }
- acquisition.cancel();
- return repository.save(acquisition);
- }
-
- private void validateAmount(BigDecimal amount) {
- if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
- throw new IllegalArgumentException("매입 금액은 0보다 커야 합니다.");
- }
- }
-}
diff --git a/src/main/java/com/acquirex/cardapproval/CardAcquisitionApprovalApplication.java b/src/main/java/com/acquirex/cardapproval/CardAcquisitionApprovalApplication.java
new file mode 100644
index 0000000..2c661cb
--- /dev/null
+++ b/src/main/java/com/acquirex/cardapproval/CardAcquisitionApprovalApplication.java
@@ -0,0 +1,15 @@
+package com.acquirex.cardapproval;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * 카드 매입 승인 경로 메인 애플리케이션
+ * Spring Boot 3.2.5 (Spring Framework 6.1.x) - Jakarta EE 10 호환
+ */
+@SpringBootApplication
+public class CardAcquisitionApprovalApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(CardAcquisitionApprovalApplication.class, args);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/acquirex/cardapproval/controller/AcquisitionApprovalController.java b/src/main/java/com/acquirex/cardapproval/controller/AcquisitionApprovalController.java
new file mode 100644
index 0000000..89fb498
--- /dev/null
+++ b/src/main/java/com/acquirex/cardapproval/controller/AcquisitionApprovalController.java
@@ -0,0 +1,67 @@
+package com.acquirex.cardapproval.controller;
+
+import com.acquirex.cardapproval.service.AcquisitionApprovalService;
+import com.acquirex.cardapproval.service.AcquisitionApprovalService.AcquisitionApprovalRequest;
+import com.acquirex.cardapproval.service.AcquisitionApprovalService.AcquisitionApprovalResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 카드 매입 승인 REST 컨트롤러
+ * Spring Boot 3.x Web (Jakarta Servlet)
+ */
+@RestController
+@RequestMapping("/api/v1/acquisitions")
+@RequiredArgsConstructor
+@Slf4j
+public class AcquisitionApprovalController {
+
+ private final AcquisitionApprovalService service;
+
+ @PostMapping
+ public ResponseEntity processAcquisition(
+ @Valid @RequestBody AcquisitionApprovalRequest request) {
+ log.info("매입 승인 요청 수신: {}", request.getApprovalNumber());
+ AcquisitionApprovalResponse response = service.processAcquisition(request);
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getAcquisition(@PathVariable Long id) {
+ log.info("매입 승인 조회: ID={}", id);
+ return ResponseEntity.ok(service.getAcquisition(id));
+ }
+
+ @GetMapping("/merchant/{merchantId}")
+ public ResponseEntity> getAcquisitionsByMerchant(
+ @PathVariable String merchantId) {
+ log.info("가맹점별 매입 승인 목록 조회: 가맹점={}", merchantId);
+ return ResponseEntity.ok(service.getAcquisitionsByMerchant(merchantId));
+ }
+
+ @GetMapping("/period")
+ public ResponseEntity> getAcquisitionsByPeriod(
+ @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
+ @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate) {
+ log.info("기간별 매입 승인 목록 조회: {} ~ {}", startDate, endDate);
+ return ResponseEntity.ok(service.getAcquisitionsByPeriod(startDate, endDate));
+ }
+
+ @PostMapping("/{id}/cancel")
+ public ResponseEntity cancelAcquisition(
+ @PathVariable Long id,
+ @RequestBody Map cancelRequest) {
+ log.info("매입 취소 요청: ID={}", id);
+ String reason = cancelRequest.getOrDefault("reason", "고객 요청");
+ return ResponseEntity.ok(service.cancelAcquisition(id, reason));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/acquirex/cardapproval/domain/AcquisitionApproval.java b/src/main/java/com/acquirex/cardapproval/domain/AcquisitionApproval.java
new file mode 100644
index 0000000..69ff5e5
--- /dev/null
+++ b/src/main/java/com/acquirex/cardapproval/domain/AcquisitionApproval.java
@@ -0,0 +1,114 @@
+package com.acquirex.cardapproval.domain;
+
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import lombok.*;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 카드 매입 승인 도메인 엔티티
+ * Jakarta Persistence API 3.1 (JPA 3.1) - Spring Boot 3.x
+ */
+@Entity
+@Table(name = "acquisition_approvals")
+@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
+public class AcquisitionApproval {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @NotBlank(message = "가맹점 번호는 필수입니다")
+ @Column(name = "merchant_id", nullable = false, length = 20)
+ private String merchantId;
+
+ @NotBlank(message = "카드번호는 필수입니다")
+ @Column(name = "card_number", nullable = false, length = 19)
+ private String cardNumber;
+
+ @NotNull @Positive
+ @Column(name = "acquisition_amount", nullable = false, precision = 15, scale = 2)
+ private BigDecimal acquisitionAmount;
+
+ @NotNull @Positive
+ @Column(name = "approval_amount", nullable = false, precision = 15, scale = 2)
+ private BigDecimal approvalAmount;
+
+ @NotBlank
+ @Column(name = "approval_number", nullable = false, length = 12)
+ private String approvalNumber;
+
+ @NotNull
+ @Column(name = "acquisition_datetime", nullable = false)
+ private LocalDateTime acquisitionDatetime;
+
+ @NotNull
+ @Enumerated(EnumType.STRING)
+ @Column(name = "approval_status", nullable = false, length = 20)
+ private ApprovalStatus approvalStatus;
+
+ @Column(name = "cancel_yn", length = 1)
+ @Builder.Default
+ private String cancelYn = "N";
+
+ @Column(name = "cancel_datetime")
+ private LocalDateTime cancelDatetime;
+
+ @Column(name = "cancel_reason", length = 200)
+ private String cancelReason;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @Column(name = "updated_at")
+ private LocalDateTime updatedAt;
+
+ @PrePersist
+ protected void onCreate() {
+ createdAt = LocalDateTime.now();
+ updatedAt = LocalDateTime.now();
+ }
+
+ @PreUpdate
+ protected void onUpdate() {
+ updatedAt = LocalDateTime.now();
+ }
+
+ public void cancel(String reason) {
+ this.cancelYn = "Y";
+ this.cancelDatetime = LocalDateTime.now();
+ this.cancelReason = reason;
+ this.approvalStatus = ApprovalStatus.CANCELLED;
+ }
+
+ public enum ApprovalStatus {
+ PENDING, APPROVED, REJECTED, CANCELLED, COMPLETED
+ }
+}
+
+/**
+ * 카드 매입 승인 레포지토리
+ * Spring Data JPA (Spring Boot 3.x)
+ */
+@Repository
+interface AcquisitionApprovalRepository extends JpaRepository {
+
+ List findByMerchantIdAndCancelYn(String merchantId, String cancelYn);
+
+ Optional findByApprovalNumber(String approvalNumber);
+
+ @Query("SELECT a FROM AcquisitionApproval a WHERE a.acquisitionDatetime BETWEEN :startDate AND :endDate")
+ List findByAcquisitionDatetimeBetween(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+}
\ No newline at end of file
diff --git a/src/main/java/com/acquirex/cardapproval/service/AcquisitionApprovalService.java b/src/main/java/com/acquirex/cardapproval/service/AcquisitionApprovalService.java
new file mode 100644
index 0000000..edc8503
--- /dev/null
+++ b/src/main/java/com/acquirex/cardapproval/service/AcquisitionApprovalService.java
@@ -0,0 +1,215 @@
+package com.acquirex.cardapproval.service;
+
+import com.acquirex.cardapproval.domain.AcquisitionApproval;
+import com.acquirex.cardapproval.domain.AcquisitionApproval.ApprovalStatus;
+import com.acquirex.cardapproval.domain.AcquisitionApprovalRepository;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 카드 매입 승인 서비스
+ * Spring Boot 3.x 트랜잭션 관리
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class AcquisitionApprovalService {
+
+ private final AcquisitionApprovalRepository repository;
+
+ @Transactional
+ public AcquisitionApprovalResponse processAcquisition(AcquisitionApprovalRequest request) {
+ log.info("매입 승인 요청 처리: 가맹점={}, 금액={}", request.getMerchantId(), request.getAcquisitionAmount());
+
+ repository.findByApprovalNumber(request.getApprovalNumber())
+ .ifPresent(existing -> {
+ throw new AcquisitionApprovalException("DUPLICATE_APPROVAL", "이미 처리된 승인번호입니다");
+ });
+
+ if (request.getAcquisitionAmount().compareTo(request.getApprovalAmount()) != 0) {
+ throw new AcquisitionApprovalException("AMOUNT_MISMATCH", "매입 금액과 승인 금액이 일치하지 않습니다");
+ }
+
+ AcquisitionApproval approval = AcquisitionApproval.builder()
+ .merchantId(request.getMerchantId())
+ .cardNumber(request.getCardNumber())
+ .acquisitionAmount(request.getAcquisitionAmount())
+ .approvalAmount(request.getApprovalAmount())
+ .approvalNumber(request.getApprovalNumber())
+ .acquisitionDatetime(request.getAcquisitionDatetime())
+ .approvalStatus(ApprovalStatus.APPROVED)
+ .cancelYn("N")
+ .build();
+
+ AcquisitionApproval saved = repository.save(approval);
+ log.info("매입 승인 완료: ID={}, 상태={}", saved.getId(), saved.getApprovalStatus());
+ return mapToResponse(saved);
+ }
+
+ @Transactional
+ public AcquisitionApprovalResponse cancelAcquisition(Long id, String reason) {
+ log.info("매입 취소 요청: ID={}, 사유={}", id, reason);
+
+ AcquisitionApproval approval = repository.findById(id)
+ .orElseThrow(() -> new AcquisitionApprovalException("NOT_FOUND", "매입 승인건을 찾을 수 없습니다: " + id));
+
+ if ("Y".equals(approval.getCancelYn())) {
+ throw new AcquisitionApprovalException("ALREADY_CANCELLED", "이미 취소된 건입니다");
+ }
+
+ approval.cancel(reason);
+ AcquisitionApproval saved = repository.save(approval);
+ log.info("매입 취소 완료: ID={}", saved.getId());
+ return mapToResponse(saved);
+ }
+
+ @Transactional(readOnly = true)
+ public AcquisitionApprovalResponse getAcquisition(Long id) {
+ return repository.findById(id)
+ .map(this::mapToResponse)
+ .orElseThrow(() -> new AcquisitionApprovalException("NOT_FOUND", "매입 승인건을 찾을 수 없습니다: " + id));
+ }
+
+ @Transactional(readOnly = true)
+ public List getAcquisitionsByMerchant(String merchantId) {
+ return repository.findByMerchantIdAndCancelYn(merchantId, "N")
+ .stream().map(this::mapToResponse).collect(Collectors.toList());
+ }
+
+ @Transactional(readOnly = true)
+ public List getAcquisitionsByPeriod(LocalDateTime startDate, LocalDateTime endDate) {
+ return repository.findByAcquisitionDatetimeBetween(startDate, endDate)
+ .stream().map(this::mapToResponse).collect(Collectors.toList());
+ }
+
+ private AcquisitionApprovalResponse mapToResponse(AcquisitionApproval approval) {
+ return AcquisitionApprovalResponse.builder()
+ .id(approval.getId())
+ .merchantId(approval.getMerchantId())
+ .cardNumber(approval.getCardNumber())
+ .acquisitionAmount(approval.getAcquisitionAmount())
+ .approvalAmount(approval.getApprovalAmount())
+ .approvalNumber(approval.getApprovalNumber())
+ .acquisitionDatetime(approval.getAcquisitionDatetime())
+ .approvalStatus(approval.getApprovalStatus())
+ .cancelYn(approval.getCancelYn())
+ .cancelDatetime(approval.getCancelDatetime())
+ .cancelReason(approval.getCancelReason())
+ .createdAt(approval.getCreatedAt())
+ .updatedAt(approval.getUpdatedAt())
+ .build();
+ }
+
+ // DTOs
+ @Data @NoArgsConstructor @AllArgsConstructor @Builder
+ public static class AcquisitionApprovalRequest {
+ @NotBlank(message = "가맹점 번호는 필수입니다")
+ private String merchantId;
+ @NotBlank(message = "카드번호는 필수입니다")
+ private String cardNumber;
+ @NotNull @Positive
+ private BigDecimal acquisitionAmount;
+ @NotNull @Positive
+ private BigDecimal approvalAmount;
+ @NotBlank
+ private String approvalNumber;
+ @NotNull
+ private LocalDateTime acquisitionDatetime;
+ }
+
+ @Data @NoArgsConstructor @AllArgsConstructor @Builder
+ public static class AcquisitionApprovalResponse {
+ private Long id;
+ private String merchantId;
+ private String cardNumber;
+ private BigDecimal acquisitionAmount;
+ private BigDecimal approvalAmount;
+ private String approvalNumber;
+ private LocalDateTime acquisitionDatetime;
+ private ApprovalStatus approvalStatus;
+ private String cancelYn;
+ private LocalDateTime cancelDatetime;
+ private String cancelReason;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+ }
+
+ // Exception
+ public static class AcquisitionApprovalException extends RuntimeException {
+ private final String errorCode;
+ public AcquisitionApprovalException(String errorCode, String message) {
+ super(message);
+ this.errorCode = errorCode;
+ }
+ }
+
+ // Global Exception Handler
+ @RestControllerAdvice
+ @Slf4j
+ public static class GlobalExceptionHandler {
+ @ExceptionHandler(AcquisitionApprovalException.class)
+ public ResponseEntity