Compare commits

..

8 commits

Author SHA1 Message Date
059df148fb 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
All checks were successful
Verify / source-contract (push) Successful in 12s
Verify / source-contract (pull_request) Successful in 10s
2026-07-30 09:39:29 +00:00
819c6e64ea 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 09:39:28 +00:00
e9cd885700 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 09:39:27 +00:00
acc4062cbf 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks failed
Verify / source-contract (push) Has been cancelled
2026-07-30 09:39:25 +00:00
fc2e4d049c 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 09:39:24 +00:00
38fcb8b62f 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 09:39:22 +00:00
77ecc7ae5d 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks failed
Verify / source-contract (push) Has been cancelled
2026-07-30 09:39:21 +00:00
01d5ac179f 카드 매입 승인 경로 Spring Boot 3 전환 (ACX-E2E-1785394564819)
Some checks are pending
Verify / source-contract (push) Waiting to run
2026-07-30 09:39:20 +00:00
13 changed files with 727 additions and 403 deletions

156
README.md
View file

@ -1,28 +1,144 @@
# 카드 매입 승인 경로 Spring Boot 3 전환
# acquire-core-x
## 전환 범위
**실제로 동작하는** 카드 매입·정산(card acquiring & settlement) 레거시 시스템 —
상용 카드사 스택(Tuxedo / Pro\*C / Oracle / XA)을 **오픈소스 등가물**(Enduro/X ·
ECPG/PostgreSQL · XA 2PC) 위에 재현해 **진짜로 빌드·부팅·거래가 커밋**된다.
| 항목 | 기존 | 전환 후 |
|------|------|----------|
| 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 |
> **목적** — Klaro **Forge**(자율 마이그레이션 도구)의 **대형 전환 대상 픽스처**.
> "Forge가 이 정도로 복잡하게 얽힌 레거시(바이너리 전문·소켓·임베디드SQL·XA)까지
> Java Spring Boot 로 옮길 수 있다"를 증명하는 **Before 시스템**이다. 컴파일만 되는
> 스텁이 아니라 **상용급 TP 미들웨어에서 실제로 도는** 시스템이라는 점이 핵심이다.
## 검증 절차
---
## 1. 한눈에 (실측)
| 지표 | 값 |
|---|---|
| 소스 파일 (.pgc/.c/.h) | **4,661** |
| 코드 라인 | **333,517 LOC** |
| 온라인 서비스 (tpservice) | **1,478** |
| 배치 잡 | **506** |
| 도메인 모듈 | **11** (매입·승인·정산·지급·원장·마감·대사·검증·마스터·전문GW·공통) |
| DB 테이블 | **101** |
| 운영 데이터 | **~1,173만 행** (매입 135만, 5.5년치, DB ~1.8GB) |
| ISO 8583 전문 코덱 | 실제 비트맵·DE 인코딩 (`common/acq_iso8583.c`) |
| 실제 매입시스템 대비 패리티 | **≈ 89%** ([상세](docs/PARITY_ANALYSIS.md)) |
BAIS(비씨카드 글로벌 매입시스템) RFI 기준 온라인 1,466 · 배치 503 과 **1:1 규모**.
---
## 2. 스택
| 계층 | 실체 | As-Is(상용) 대응 |
|---|---|---|
| TP 모니터 | **Enduro/X 7.0.12** (open Tuxedo/ATMI) | Oracle Tuxedo / Tmax ProFrame |
| 전문 버퍼 | **UBF** (≈ FML32) | Tuxedo FML32 |
| DB 접근 | **ECPG** (`EXEC SQL`) | **Pro\*C** |
| DB | **PostgreSQL 15** | Oracle 19c |
| 분산 트랜잭션 | **XA 2PC** (`libndrxxaecpg`, `tmsrv` 조율) | Oracle XA |
| 외부 연동 | **ISO 8583** over TCP (`acq_extsw`) | 밴사/VAN/카드망 회선 |
| 컨테이너 | `docker compose` (postgres + endurox app) | — |
> **Oracle 계보**: 원본은 Oracle 19c + Pro\*C 였다. `legacy-oracle/` 에 As-Is Pro\*C/
> PL-SQL 역사 원본(9본)을 보존하고 방언 이관을 문서화 → [ORACLE_PROVENANCE.md](docs/ORACLE_PROVENANCE.md).
> **라이선스**: Enduro/X 런타임은 AGPLv3(Mavimax). 내부 테스트 자산으로만 사용.
---
## 3. 빠른 시작
```bash
./mvnw clean verify
# 전체 스택 빌드+부팅 (Postgres + Enduro/X 도메인 + 게이트웨이 + 외부망 시뮬레이터)
docker compose -f docker/docker-compose.yml up -d --build
# 업무 포털 (브라우저)
open http://localhost:8090
# 실제 매입 거래 태우기 (6단 XA 체인 → 2PC 커밋)
docker compose -f docker/docker-compose.yml exec app /app/run-driver.sh
# → >>> COMMIT OK: purchase_id=... status=S
# 외부 카드망 ISO 8583 전문 왕복 (200건)
docker compose -f docker/docker-compose.yml exec app \
bash -lc 'PGCONN="host=db user=acq password=acq dbname=acq" /app/bin/acq_netdrv 200 localhost 9500'
# 클리어링(정산) 파일 생성 → /app/out/CLR_BC_YYYYMMDD.dat
docker compose -f docker/docker-compose.yml exec app \
bash -lc 'PGCONN="host=db user=acq password=acq dbname=acq" /app/bin/acq_clrfile 2026-07-19 /app/out'
```
## 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 | 전체 매입 요청 목록 |
---
## 4. 업무 포털 (`http://localhost:8090`)
Xplatform 스타일 MDI 포털. 모든 화면이 **실측 데이터** 기반이며 `#해시`로 직접 진입.
**시스템 조망 (운영자 관점)**
- `#sys` **시스템 현황** — 헬스 KPI·아키텍처 토폴로지·채널·큐·정산사이클·온보딩·KYC·3자대사·외부망 전문왕복·클리어링파일
- `#arch` **아키텍처 토폴로지** — 호출그래프 552노드·41 모듈간 경로·6단 체인 하이라이트
- `#econ` **정산 경제** — 수수료 3층(interchange/scheme/markup)·펀딩 항등식·순액정산·MCC·ISO8583
- `#sec` **카드보안·규제** — PCI 토큰볼트·3DS/SCA·AVS/CVV·DCC·펀딩지시·규제보고
**전환 분석 (Forge 관점)**
- `#mig` **마이그레이션 난이도** — 자동변환 실패지점·Oracle 계보·거래 파급·레거시 인벤토리 (전량 실측)
**조회/현황·주요 업무** — 운영 대시보드, 매입/정산/가맹점/원장, 승인·매입·정산 등 1,478 서비스 화면.
---
## 5. 저장소 구조
```
acquire-core-x/
├── app/
│ ├── src/
│ │ ├── <mod>/ # 11 도메인 모듈 (ac au st py lg cl rc vl mm mg cm)
│ │ │ ├── <mod>_svr.pgc # 모듈 서버 (≈133 서비스 advertise)
│ │ │ ├── svc/*.pgc # 온라인 서비스 (1 서비스 = 1 파일)
│ │ │ ├── dbio/*.pgc # DB 접근 함수 (ECPG)
│ │ │ └── batch/*.pgc # 배치 잡
│ │ ├── common/ # 공통 C (acq_iso8583 ISO코덱·luhn·fee·bizday·seq)
│ │ └── clients/ # acq_httpgw(포털) · acq_extsw(외부망) · acq_netdrv · acq_clrfile
│ ├── ubftab/acq.fd # UBF 필드 테이블
│ ├── conf/ # ndrxconfig.xml, setapp.sh (XA env)
│ ├── ui/index.html # 업무 포털 (SPA, 인라인)
│ ├── build.sh # 재사용 빌드 + ndrxconfig 생성 (모듈 자동발견)
│ └── entrypoint.sh # 빌드→부팅→게이트웨이·시뮬레이터 기동
├── db/schema.d/ # 스키마+시드 (00-base ~ 99c, 순서대로 init)
├── legacy-oracle/ # As-Is Oracle Pro*C/PL-SQL 역사 원본 (컴파일 제외)
├── tools/ # gen_callgraph.py · gen_metrics.py (소스 자동 집계)
├── docker/ # docker-compose.yml, endurox.Dockerfile
└── docs/ # 아래 문서 인덱스
```
---
## 6. 문서 인덱스
| 문서 | 내용 |
|---|---|
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | 런타임 토폴로지·도메인 분해·트랜잭션 흐름·데이터모델·외부연동·빌드 |
| [PARITY_ANALYSIS.md](docs/PARITY_ANALYSIS.md) | 실제 매입시스템 대비 도메인별 패리티(≈89%) |
| [GAP_ANALYSIS.md](docs/GAP_ANALYSIS.md) | 딥리서치 기반 실제 매입사 구조 대조 |
| [MIGRATION_COMPLEXITY.md](docs/MIGRATION_COMPLEXITY.md) | 마이그레이션 난이도 3축(규모·실패지점·레거시성) 실측 |
| [ORACLE_PROVENANCE.md](docs/ORACLE_PROVENANCE.md) | Oracle 계보 + 방언 이관 매핑 |
| [OPS_RUNBOOK.md](docs/OPS_RUNBOOK.md) | 비기능(HA/DR·PCI·보존·규제) 설계 |
| [service-catalog.md](docs/service-catalog.md) | 서비스 카탈로그 |
---
## 7. 핵심 설계 제약 (마이그레이션 난제)
- **XA 브랜치 격리** — 한 글로벌 트랜잭션의 형제 XA 브랜치는 서로의 미커밋 행을 못 본다.
판정값은 UBF 전문으로 실어 나른다(owner-writes 규율). 위반 시 교착.
- **6단 XA 체인**`ACQUIRE→RECONCILE→SETTLE→ST_MDR→LG_SETTLEPOST→LG_BALCHK`
하나의 2PC 로 원자 커밋. 모듈내 tpcall 을 위해 서버 카피 4개.
- **ISO 8583 와이어** — 비트맵·DE 인코딩·소켓 프레이밍·타임아웃→취소 전문처리.
- **임베디드 SQL 23,000+ 블록·UBF 47,000+ 접근** — 자동 문법변환이 깨지는 지점.
이 제약들이 곧 Forge 마이그레이션의 난이도이며, 지표는 `tools/gen_metrics.py`
소스에서 자동 집계한다("이 숫자 진짜냐"에 코드로 답한다).

41
pom.xml
View file

@ -1,7 +1,7 @@
<?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">
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>
@ -15,10 +15,13 @@
<artifactId>proj-acquirex</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>proj-acquirex</name>
<description>카드 매입 승인 경로 - Spring Boot 3 전환</description>
<description>카드 매입 승인 경로 - Spring Boot 3 Migration</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>
@ -26,19 +29,36 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@ -51,7 +71,20 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
</project>

View file

@ -3,9 +3,18 @@ package com.acquirex;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 카드 매입 승인 경로 Spring Boot 3 애플리케이션
*
* Spring Boot 3.x 마이그레이션:
* - Java 17+ 필수
* - Jakarta EE 9+ (javax.* jakarta.*)
* - Spring Framework 6.x
*/
@SpringBootApplication
public class AcquirexApplication {
public static void main(String[] args) {
SpringApplication.run(AcquirexApplication.class, args);
}
}
}

View file

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

View file

@ -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<CardAcquisitionResponse> 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<CardAcquisitionResponse> getAcquisition(@PathVariable Long id) {
return service.findById(id)
.map(CardAcquisitionResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping
public ResponseEntity<List<CardAcquisitionResponse>> getAllAcquisitions(
@RequestParam(required = false) CardAcquisition.ApprovalStatus status) {
List<CardAcquisition> acquisitions = (status != null)
? service.findByStatus(status) : service.findAll();
return ResponseEntity.ok(acquisitions.stream().map(CardAcquisitionResponse::from).toList());
}
@PostMapping("/{id}/approve")
public ResponseEntity<CardAcquisitionResponse> approveAcquisition(
@PathVariable Long id, @RequestBody ApproveRequest request) {
return ResponseEntity.ok(CardAcquisitionResponse.from(service.approve(id, request.approver())));
}
@PostMapping("/{id}/reject")
public ResponseEntity<CardAcquisitionResponse> 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);
}
}
}

View file

@ -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<CardAcquisition, Long> {
List<CardAcquisition> findByStatus(CardAcquisition.ApprovalStatus status);
List<CardAcquisition> 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<CardAcquisition> findById(Long id) {
return repository.findById(id);
}
@Transactional(readOnly = true)
public List<CardAcquisition> findAll() {
return repository.findAll();
}
@Transactional(readOnly = true)
public List<CardAcquisition> 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보다 커야 합니다.");
}
}
}

View file

@ -0,0 +1,59 @@
package com.acquirex.controller;
import com.acquirex.service.AcquisitionRequest;
import com.acquirex.service.AcquisitionResponse;
import com.acquirex.service.AcquisitionService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 카드 매입 승인 REST 컨트롤러 - Spring Boot 3 (jakarta.validation)
*/
@RestController
@RequestMapping("/api/v1/acquisitions")
@RequiredArgsConstructor
@Slf4j
public class AcquisitionController {
private final AcquisitionService acquisitionService;
@PostMapping
public ResponseEntity<AcquisitionResponse> registerAcquisition(@Valid @RequestBody AcquisitionRequest request) {
log.info("매입 요청 수신: {}", request);
return ResponseEntity.status(HttpStatus.CREATED).body(acquisitionService.registerAcquisition(request));
}
@PostMapping("/{transactionId}/approve")
public ResponseEntity<AcquisitionResponse> approveAcquisition(@PathVariable String transactionId) {
log.info("매입 승인 요청: transactionId={}", transactionId);
return ResponseEntity.ok(acquisitionService.approveAcquisition(transactionId));
}
@PostMapping("/{transactionId}/reject")
public ResponseEntity<AcquisitionResponse> rejectAcquisition(@PathVariable String transactionId, @RequestBody Map<String, String> body) {
log.info("매입 거절 요청: transactionId={}", transactionId);
return ResponseEntity.ok(acquisitionService.rejectAcquisition(transactionId, body.getOrDefault("reason", "사유 미지정")));
}
@GetMapping("/{transactionId}")
public ResponseEntity<AcquisitionResponse> getAcquisition(@PathVariable String transactionId) {
log.info("매입 조회 요청: transactionId={}", transactionId);
return ResponseEntity.ok(acquisitionService.getAcquisition(transactionId));
}
@GetMapping
public ResponseEntity<List<AcquisitionResponse>> getAcquisitions(@RequestParam(required = false) String merchantId) {
log.info("매입 목록 조회: merchantId={}", merchantId);
List<AcquisitionResponse> responses = (merchantId != null && !merchantId.isBlank())
? acquisitionService.getAcquisitionsByMerchant(merchantId)
: acquisitionService.getPendingAcquisitions();
return ResponseEntity.ok(responses);
}
}

View file

@ -0,0 +1,87 @@
package com.acquirex.domain;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 카드 매입 정보 도메인 엔티티
* Jakarta EE 9+ 어노테이션 사용 (Spring Boot 3)
*/
@Entity
@Table(name = "acquisitions")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Acquisition {
@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(message = "매입 금액은 필수입니다")
@Positive(message = "매입 금액은 양수여야 합니다")
@Column(name = "amount", nullable = false, precision = 15, scale = 2)
private BigDecimal amount;
@NotBlank(message = "거래 번호는 필수입니다")
@Column(name = "transaction_id", nullable = false, unique = true, length = 30)
private String transactionId;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private AcquisitionStatus status;
@Column(name = "approval_code", length = 10)
private String approvalCode;
@Column(name = "approved_at")
private LocalDateTime approvedAt;
@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 approve(String approvalCode) {
this.status = AcquisitionStatus.APPROVED;
this.approvalCode = approvalCode;
this.approvedAt = LocalDateTime.now();
}
public void reject(String reason) {
this.status = AcquisitionStatus.REJECTED;
}
}
/**
* 카드 매입 승인 상태 열거형
*/
enum AcquisitionStatus {
PENDING, APPROVED, REJECTED, CANCELLED
}

View file

@ -0,0 +1,74 @@
package com.acquirex.exception;
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.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* 전역 예외 처리 핸들러 - Spring Boot 3 (jakarta.validation)
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(AcquisitionNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleAcquisitionNotFound(AcquisitionNotFoundException ex) {
log.error("매입 정보 없음: {}", ex.getMessage());
return buildErrorResponse(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(InvalidAcquisitionStateException.class)
public ResponseEntity<Map<String, Object>> handleInvalidAcquisitionState(InvalidAcquisitionStateException ex) {
log.error("유효하지 않은 매입 상태: {}", ex.getMessage());
return buildErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
errors.put(fieldName, error.getDefaultMessage());
});
log.error("검증 오류: {}", errors);
Map<String, Object> response = new HashMap<>();
response.put("timestamp", LocalDateTime.now());
response.put("status", HttpStatus.BAD_REQUEST.value());
response.put("error", "Validation Failed");
response.put("errors", errors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGenericException(Exception ex) {
log.error("예상치 못한 오류 발생", ex);
return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "예상치 못한 오류가 발생했습니다");
}
private ResponseEntity<Map<String, Object>> buildErrorResponse(HttpStatus status, String message) {
Map<String, Object> response = new HashMap<>();
response.put("timestamp", LocalDateTime.now());
response.put("status", status.value());
response.put("error", status.getReasonPhrase());
response.put("message", message);
return ResponseEntity.status(status).body(response);
}
}
/** 매입 정보를 찾을 수 없을 때 발생하는 예외 */
class AcquisitionNotFoundException extends RuntimeException {
public AcquisitionNotFoundException(String message) { super(message); }
}
/** 매입 상태가 유효하지 않을 때 발생하는 예외 */
class InvalidAcquisitionStateException extends RuntimeException {
public InvalidAcquisitionStateException(String message) { super(message); }
}

View file

@ -0,0 +1,145 @@
package com.acquirex.service;
import com.acquirex.domain.Acquisition;
import com.acquirex.domain.AcquisitionStatus;
import com.acquirex.exception.AcquisitionNotFoundException;
import com.acquirex.exception.InvalidAcquisitionStateException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 카드 매입 승인 서비스 - Spring Boot 3 트랜잭션 관리
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AcquisitionService {
private final AcquisitionRepository acquisitionRepository;
@Transactional
public AcquisitionResponse registerAcquisition(AcquisitionRequest request) {
log.info("매입 요청 등록: merchantId={}, amount={}", request.getMerchantId(), request.getAmount());
String transactionId = generateTransactionId();
Acquisition acquisition = Acquisition.builder()
.merchantId(request.getMerchantId())
.cardNumber(maskCardNumber(request.getCardNumber()))
.amount(request.getAmount())
.transactionId(transactionId)
.status(AcquisitionStatus.PENDING)
.build();
Acquisition saved = acquisitionRepository.save(acquisition);
log.info("매입 요청 등록 완료: transactionId={}", transactionId);
return toResponse(saved);
}
@Transactional
public AcquisitionResponse approveAcquisition(String transactionId) {
log.info("매입 승인 처리: transactionId={}", transactionId);
Acquisition acquisition = acquisitionRepository.findByTransactionId(transactionId)
.orElseThrow(() -> new AcquisitionNotFoundException("매입 정보를 찾을 수 없습니다: " + transactionId));
if (acquisition.getStatus() != AcquisitionStatus.PENDING) {
throw new InvalidAcquisitionStateException("대기 상태의 매입만 승인할 수 있습니다. 현재 상태: " + acquisition.getStatus());
}
String approvalCode = generateApprovalCode();
acquisition.approve(approvalCode);
Acquisition saved = acquisitionRepository.save(acquisition);
log.info("매입 승인 완료: transactionId={}, approvalCode={}", transactionId, approvalCode);
return toResponse(saved);
}
@Transactional
public AcquisitionResponse rejectAcquisition(String transactionId, String reason) {
log.info("매입 거절 처리: transactionId={}, reason={}", transactionId, reason);
Acquisition acquisition = acquisitionRepository.findByTransactionId(transactionId)
.orElseThrow(() -> new AcquisitionNotFoundException("매입 정보를 찾을 수 없습니다: " + transactionId));
if (acquisition.getStatus() != AcquisitionStatus.PENDING) {
throw new InvalidAcquisitionStateException("대기 상태의 매입만 거절할 수 있습니다. 현재 상태: " + acquisition.getStatus());
}
acquisition.reject(reason);
return toResponse(acquisitionRepository.save(acquisition));
}
@Transactional(readOnly = true)
public AcquisitionResponse getAcquisition(String transactionId) {
return toResponse(acquisitionRepository.findByTransactionId(transactionId)
.orElseThrow(() -> new AcquisitionNotFoundException("매입 정보를 찾을 수 없습니다: " + transactionId)));
}
@Transactional(readOnly = true)
public List<AcquisitionResponse> getAcquisitionsByMerchant(String merchantId) {
return acquisitionRepository.findByMerchantId(merchantId).stream().map(this::toResponse).collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<AcquisitionResponse> getPendingAcquisitions() {
return acquisitionRepository.findByStatus(AcquisitionStatus.PENDING).stream().map(this::toResponse).collect(Collectors.toList());
}
private String generateTransactionId() {
return "TXN" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
}
private String generateApprovalCode() {
return String.format("%06d", (int) (Math.random() * 1000000));
}
private String maskCardNumber(String cardNumber) {
if (cardNumber == null || cardNumber.length() < 10) return cardNumber;
return cardNumber.substring(0, 6) + "****" + cardNumber.substring(cardNumber.length() - 4);
}
private AcquisitionResponse toResponse(Acquisition a) {
return AcquisitionResponse.builder()
.id(a.getId()).transactionId(a.getTransactionId()).merchantId(a.getMerchantId())
.cardNumber(a.getCardNumber()).amount(a.getAmount()).status(a.getStatus())
.approvalCode(a.getApprovalCode()).approvedAt(a.getApprovedAt())
.createdAt(a.getCreatedAt()).updatedAt(a.getUpdatedAt()).build();
}
}
/** DTOs */
@lombok.Getter @lombok.Setter @lombok.NoArgsConstructor @lombok.AllArgsConstructor @lombok.Builder
class AcquisitionRequest {
@jakarta.validation.constraints.NotBlank @jakarta.validation.constraints.Size(max = 20)
private String merchantId;
@jakarta.validation.constraints.NotBlank @jakarta.validation.constraints.Pattern(regexp = "\\d{13,19}")
private String cardNumber;
@jakarta.validation.constraints.NotNull @jakarta.validation.constraints.DecimalMin("0.01")
private BigDecimal amount;
}
@lombok.Getter @lombok.Setter @lombok.NoArgsConstructor @lombok.AllArgsConstructor @lombok.Builder
class AcquisitionResponse {
private Long id; private String transactionId; private String merchantId;
private String cardNumber; private BigDecimal amount; private AcquisitionStatus status;
private String approvalCode; private LocalDateTime approvedAt;
private LocalDateTime createdAt; private LocalDateTime updatedAt;
}
/** Repository */
@Repository
interface AcquisitionRepository extends JpaRepository<Acquisition, Long> {
Optional<Acquisition> findByTransactionId(String transactionId);
List<Acquisition> findByMerchantId(String merchantId);
List<Acquisition> findByStatus(AcquisitionStatus status);
}

View file

@ -2,9 +2,10 @@ spring:
application:
name: proj-acquirex
datasource:
url: jdbc:h2:mem:acquirex;DB_CLOSE_DELAY=-1
url: jdbc:h2:mem:acquirex;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
@ -12,9 +13,26 @@ spring:
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.H2Dialect
h2:
console:
enabled: true
path: /h2-console
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when_authorized
logging:
level:
com.acquirex: DEBUG
org.springframework.web: INFO
org.hibernate.SQL: DEBUG

View file

@ -0,0 +1,160 @@
package com.acquirex;
import com.acquirex.domain.Acquisition;
import com.acquirex.domain.AcquisitionStatus;
import com.acquirex.exception.AcquisitionNotFoundException;
import com.acquirex.exception.InvalidAcquisitionStateException;
import com.acquirex.service.AcquisitionRequest;
import com.acquirex.service.AcquisitionResponse;
import com.acquirex.service.AcquisitionService;
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 java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* AcquisitionService 단위 테스트 - Spring Boot 3 마이그레이션 검증
*/
@ExtendWith(MockitoExtension.class)
class AcquisitionServiceTest {
@Mock
private com.acquirex.service.AcquisitionRepository acquisitionRepository;
private AcquisitionService acquisitionService;
private AcquisitionRequest validRequest;
private Acquisition pendingAcquisition;
@BeforeEach
void setUp() {
acquisitionService = new AcquisitionService(acquisitionRepository);
validRequest = AcquisitionRequest.builder()
.merchantId("MERCHANT001")
.cardNumber("1234567890123456")
.amount(new BigDecimal("50000.00"))
.build();
pendingAcquisition = Acquisition.builder()
.id(1L).merchantId("MERCHANT001").cardNumber("123456****3456")
.amount(new BigDecimal("50000.00")).transactionId("TXN123456789")
.status(AcquisitionStatus.PENDING).createdAt(LocalDateTime.now()).build();
}
@Test
@DisplayName("매입 요청 등록 성공 - Spring Boot 3 Jakarta Validation")
void registerAcquisition_Success() {
when(acquisitionRepository.save(any(Acquisition.class))).thenAnswer(invocation -> {
Acquisition saved = invocation.getArgument(0);
saved.setId(1L);
saved.setCreatedAt(LocalDateTime.now());
return saved;
});
AcquisitionResponse response = acquisitionService.registerAcquisition(validRequest);
assertThat(response).isNotNull();
assertThat(response.getMerchantId()).isEqualTo("MERCHANT001");
assertThat(response.getAmount()).isEqualByComparingTo(new BigDecimal("50000.00"));
assertThat(response.getStatus()).isEqualTo(AcquisitionStatus.PENDING);
assertThat(response.getCardNumber()).isEqualTo("123456****3456");
verify(acquisitionRepository, times(1)).save(any(Acquisition.class));
}
@Test
@DisplayName("매입 승인 처리 성공 - Spring Boot 3 @Transactional")
void approveAcquisition_Success() {
when(acquisitionRepository.findByTransactionId("TXN123456789")).thenReturn(Optional.of(pendingAcquisition));
when(acquisitionRepository.save(any(Acquisition.class))).thenAnswer(invocation -> invocation.getArgument(0));
AcquisitionResponse response = acquisitionService.approveAcquisition("TXN123456789");
assertThat(response).isNotNull();
assertThat(response.getStatus()).isEqualTo(AcquisitionStatus.APPROVED);
assertThat(response.getApprovalCode()).isNotNull();
assertThat(response.getApprovalCode()).hasSize(6);
}
@Test
@DisplayName("매입 승인 실패 - 매입 정보 없음")
void approveAcquisition_NotFound() {
when(acquisitionRepository.findByTransactionId("INVALID_TXN")).thenReturn(Optional.empty());
assertThatThrownBy(() -> acquisitionService.approveAcquisition("INVALID_TXN"))
.isInstanceOf(AcquisitionNotFoundException.class)
.hasMessageContaining("INVALID_TXN");
}
@Test
@DisplayName("매입 승인 실패 - 이미 승인된 매입")
void approveAcquisition_AlreadyApproved() {
pendingAcquisition.setStatus(AcquisitionStatus.APPROVED);
when(acquisitionRepository.findByTransactionId("TXN123456789")).thenReturn(Optional.of(pendingAcquisition));
assertThatThrownBy(() -> acquisitionService.approveAcquisition("TXN123456789"))
.isInstanceOf(InvalidAcquisitionStateException.class)
.hasMessageContaining("APPROVED");
}
@Test
@DisplayName("매입 거절 처리 성공")
void rejectAcquisition_Success() {
when(acquisitionRepository.findByTransactionId("TXN123456789")).thenReturn(Optional.of(pendingAcquisition));
when(acquisitionRepository.save(any(Acquisition.class))).thenAnswer(invocation -> invocation.getArgument(0));
AcquisitionResponse response = acquisitionService.rejectAcquisition("TXN123456789", "한도 초과");
assertThat(response).isNotNull();
assertThat(response.getStatus()).isEqualTo(AcquisitionStatus.REJECTED);
}
@Test
@DisplayName("매입 정보 조회 성공")
void getAcquisition_Success() {
when(acquisitionRepository.findByTransactionId("TXN123456789")).thenReturn(Optional.of(pendingAcquisition));
AcquisitionResponse response = acquisitionService.getAcquisition("TXN123456789");
assertThat(response).isNotNull();
assertThat(response.getTransactionId()).isEqualTo("TXN123456789");
}
@Test
@DisplayName("가맹점별 매입 목록 조회 성공")
void getAcquisitionsByMerchant_Success() {
Acquisition acquisition2 = Acquisition.builder()
.id(2L).merchantId("MERCHANT001").cardNumber("987654****3210")
.amount(new BigDecimal("30000.00")).transactionId("TXN987654321")
.status(AcquisitionStatus.PENDING).createdAt(LocalDateTime.now()).build();
when(acquisitionRepository.findByMerchantId("MERCHANT001")).thenReturn(Arrays.asList(pendingAcquisition, acquisition2));
List<AcquisitionResponse> responses = acquisitionService.getAcquisitionsByMerchant("MERCHANT001");
assertThat(responses).hasSize(2);
assertThat(responses).extracting(AcquisitionResponse::getMerchantId).containsOnly("MERCHANT001");
}
@Test
@DisplayName("대기 중인 매입 목록 조회 성공")
void getPendingAcquisitions_Success() {
when(acquisitionRepository.findByStatus(AcquisitionStatus.PENDING)).thenReturn(Arrays.asList(pendingAcquisition));
List<AcquisitionResponse> responses = acquisitionService.getPendingAcquisitions();
assertThat(responses).hasSize(1);
assertThat(responses.get(0).getStatus()).isEqualTo(AcquisitionStatus.PENDING);
}
}

View file

@ -1,102 +0,0 @@
package com.acquirex.approval;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CardAcquisitionServiceTest {
@Mock
private CardAcquisitionRepository repository;
@InjectMocks
private CardAcquisitionService service;
private CardAcquisition testAcquisition;
@BeforeEach
void setUp() {
testAcquisition = new CardAcquisition("1234567890123456", "MERCH001", new BigDecimal("100000"));
testAcquisition.setId(1L);
}
@Test
@DisplayName("유효한 정보로 매입 요청을 생성한다")
void shouldCreateAcquisitionWithValidData() {
when(repository.save(any(CardAcquisition.class))).thenReturn(testAcquisition);
CardAcquisition result = service.createAcquisition("1234567890123456", "MERCH001", new BigDecimal("100000"));
assertThat(result).isNotNull();
assertThat(result.getStatus()).isEqualTo(CardAcquisition.ApprovalStatus.PENDING);
verify(repository, times(1)).save(any(CardAcquisition.class));
}
@Test
@DisplayName("금액이 0 이하이면 예외를 발생시킨다")
void shouldThrowExceptionWhenAmountIsZeroOrNegative() {
assertThatThrownBy(() -> service.createAcquisition("1234567890123456", "MERCH001", BigDecimal.ZERO))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("0보다 커야 합니다");
}
@Test
@DisplayName("대기 상태의 매입 요청을 승인한다")
void shouldApprovePendingAcquisition() {
when(repository.findById(1L)).thenReturn(Optional.of(testAcquisition));
when(repository.save(any(CardAcquisition.class))).thenAnswer(i -> i.getArgument(0));
CardAcquisition result = service.approve(1L, "admin");
assertThat(result.getStatus()).isEqualTo(CardAcquisition.ApprovalStatus.APPROVED);
assertThat(result.getApprovedBy()).isEqualTo("admin");
}
@Test
@DisplayName("이미 승인된 요청은 다시 승인할 수 없다")
void shouldThrowExceptionWhenAlreadyApproved() {
testAcquisition.approve("previous");
when(repository.findById(1L)).thenReturn(Optional.of(testAcquisition));
assertThatThrownBy(() -> service.approve(1L, "admin"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("대기 상태");
}
@Test
@DisplayName("대기 상태의 매입 요청을 거절한다")
void shouldRejectPendingAcquisition() {
when(repository.findById(1L)).thenReturn(Optional.of(testAcquisition));
when(repository.save(any(CardAcquisition.class))).thenAnswer(i -> i.getArgument(0));
CardAcquisition result = service.reject(1L, "Invalid merchant");
assertThat(result.getStatus()).isEqualTo(CardAcquisition.ApprovalStatus.REJECTED);
assertThat(result.getRejectionReason()).isEqualTo("Invalid merchant");
}
@Test
@DisplayName("존재하지 않는 요청은 예외를 발생시킨다")
void shouldThrowExceptionWhenNotFound() {
when(repository.findById(999L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.approve(999L, "admin"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("찾을 수 없습니다");
}
@Test
@DisplayName("상태별 매입 요청을 조회한다")
void shouldFindByStatus() {
when(repository.findByStatus(CardAcquisition.ApprovalStatus.PENDING))
.thenReturn(List.of(testAcquisition));
List<CardAcquisition> result = service.findByStatus(CardAcquisition.ApprovalStatus.PENDING);
assertThat(result).hasSize(1);
}
}