Compare commits
8 commits
main
...
forge/ACX-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5461d75291 | |||
| 91e77cb666 | |||
| 05dcb24cb3 | |||
| 0d4a244b56 | |||
| e56f14722c | |||
| ead3a4f1e4 | |||
| 6b56326638 | |||
| 3f0712b3cc |
14 changed files with 669 additions and 424 deletions
156
README.md
156
README.md
|
|
@ -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` 가
|
||||
소스에서 자동 집계한다("이 숫자 진짜냐"에 코드로 답한다).
|
||||
|
|
|
|||
6
pom.xml
6
pom.xml
|
|
@ -12,9 +12,9 @@
|
|||
</parent>
|
||||
|
||||
<groupId>com.acquirex</groupId>
|
||||
<artifactId>proj-acquirex</artifactId>
|
||||
<artifactId>card-acquisition</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<name>proj-acquirex</name>
|
||||
<name>card-acquisition</name>
|
||||
<description>카드 매입 승인 경로 - Spring Boot 3 전환</description>
|
||||
|
||||
<properties>
|
||||
|
|
@ -54,4 +54,4 @@
|
|||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
</project>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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보다 커야 합니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
package com.acquirex;
|
||||
package com.acquirex.cardacquisition;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class AcquirexApplication {
|
||||
public class CardAcquisitionApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AcquirexApplication.class, args);
|
||||
SpringApplication.run(CardAcquisitionApplication.class, args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.acquirex.cardacquisition.controller;
|
||||
|
||||
import com.acquirex.cardacquisition.dto.AcquisitionRequest;
|
||||
import com.acquirex.cardacquisition.dto.AcquisitionResponse;
|
||||
import com.acquirex.cardacquisition.service.CardAcquisitionService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Spring Boot 3 전환: javax.servlet → jakarta.servlet */
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/acquisitions")
|
||||
public class CardAcquisitionController {
|
||||
|
||||
private final CardAcquisitionService service;
|
||||
|
||||
public CardAcquisitionController(CardAcquisitionService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<AcquisitionResponse> requestAcquisition(@Valid @RequestBody AcquisitionRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(service.requestAcquisition(request));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<AcquisitionResponse> getAcquisition(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(service.getAcquisition(id));
|
||||
}
|
||||
|
||||
@GetMapping("/merchant/{merchantId}")
|
||||
public ResponseEntity<List<AcquisitionResponse>> getAcquisitionsByMerchant(@PathVariable String merchantId) {
|
||||
return ResponseEntity.ok(service.getAcquisitionsByMerchant(merchantId));
|
||||
}
|
||||
|
||||
@GetMapping("/pending")
|
||||
public ResponseEntity<List<AcquisitionResponse>> getPendingAcquisitions() {
|
||||
return ResponseEntity.ok(service.getPendingAcquisitions());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/cancel")
|
||||
public ResponseEntity<AcquisitionResponse> cancelAcquisition(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(service.cancelAcquisition(id));
|
||||
}
|
||||
}
|
||||
|
||||
/** Spring Boot 3 전환: javax.validation → jakarta.validation */
|
||||
@RestControllerAdvice
|
||||
class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(CardAcquisitionService.AcquisitionNotFoundException.class)
|
||||
public ResponseEntity<java.util.Map<String, Object>> handleNotFound(CardAcquisitionService.AcquisitionNotFoundException ex) {
|
||||
return buildErrorResponse(HttpStatus.NOT_FOUND, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(CardAcquisitionService.InvalidAcquisitionException.class)
|
||||
public ResponseEntity<java.util.Map<String, Object>> handleInvalid(CardAcquisitionService.InvalidAcquisitionException ex) {
|
||||
return buildErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalStateException.class)
|
||||
public ResponseEntity<java.util.Map<String, Object>> handleIllegalState(IllegalStateException ex) {
|
||||
return buildErrorResponse(HttpStatus.CONFLICT, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(org.springframework.web.bind.MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<java.util.Map<String, Object>> handleValidationErrors(
|
||||
org.springframework.web.bind.MethodArgumentNotValidException ex) {
|
||||
java.util.Map<String, String> fieldErrors = new java.util.HashMap<>();
|
||||
ex.getBindingResult().getFieldErrors().forEach(e -> fieldErrors.put(e.getField(), e.getDefaultMessage()));
|
||||
|
||||
java.util.Map<String, Object> body = new java.util.HashMap<>();
|
||||
body.put("timestamp", LocalDateTime.now());
|
||||
body.put("status", HttpStatus.BAD_REQUEST.value());
|
||||
body.put("error", "Validation Failed");
|
||||
body.put("fieldErrors", fieldErrors);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
|
||||
}
|
||||
|
||||
private ResponseEntity<java.util.Map<String, Object>> buildErrorResponse(HttpStatus status, String message) {
|
||||
java.util.Map<String, Object> body = new java.util.HashMap<>();
|
||||
body.put("timestamp", LocalDateTime.now());
|
||||
body.put("status", status.value());
|
||||
body.put("error", status.getReasonPhrase());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
package com.acquirex.cardacquisition.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 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 = "merchant_id", nullable = false)
|
||||
private String merchantId;
|
||||
|
||||
@Column(name = "card_number", nullable = false)
|
||||
private String cardNumber;
|
||||
|
||||
@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)
|
||||
private AcquisitionStatus status = AcquisitionStatus.PENDING;
|
||||
|
||||
@Column(name = "approval_code")
|
||||
private String approvalCode;
|
||||
|
||||
@Column(name = "request_time", nullable = false)
|
||||
private LocalDateTime requestTime = LocalDateTime.now();
|
||||
|
||||
@Column(name = "approval_time")
|
||||
private LocalDateTime approvalTime;
|
||||
|
||||
@Column(name = "reject_reason")
|
||||
private String rejectReason;
|
||||
|
||||
public CardAcquisition() {}
|
||||
|
||||
public CardAcquisition(String merchantId, String cardNumber, BigDecimal amount, String currency) {
|
||||
this.merchantId = merchantId;
|
||||
this.cardNumber = cardNumber;
|
||||
this.amount = amount;
|
||||
this.currency = currency;
|
||||
this.status = AcquisitionStatus.PENDING;
|
||||
this.requestTime = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getMerchantId() { return merchantId; }
|
||||
public void setMerchantId(String merchantId) { this.merchantId = merchantId; }
|
||||
public String getCardNumber() { return cardNumber; }
|
||||
public void setCardNumber(String cardNumber) { this.cardNumber = cardNumber; }
|
||||
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; }
|
||||
|
||||
public void approve(String approvalCode) {
|
||||
this.status = AcquisitionStatus.APPROVED;
|
||||
this.approvalCode = approvalCode;
|
||||
this.approvalTime = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public void reject(String reason) {
|
||||
this.status = AcquisitionStatus.REJECTED;
|
||||
this.rejectReason = reason;
|
||||
this.approvalTime = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/** Spring Boot 3 전환: javax.persistence → jakarta.persistence */
|
||||
public enum AcquisitionStatus {
|
||||
PENDING, APPROVED, REJECTED, CANCELLED
|
||||
}
|
||||
}
|
||||
|
||||
/** Spring Boot 3 전환: javax.validation → jakarta.validation */
|
||||
class AcquisitionRequest {
|
||||
@NotBlank(message = "상점 ID는 필수입니다")
|
||||
private String merchantId;
|
||||
|
||||
@NotBlank(message = "카드번호는 필수입니다")
|
||||
@Pattern(regexp = "\\d{10,19}", message = "유효하지 않은 카드번호 형식입니다")
|
||||
private String cardNumber;
|
||||
|
||||
@NotNull(message = "금액은 필수입니다")
|
||||
@DecimalMin(value = "0.01", message = "금액은 0보다 커야 합니다")
|
||||
private BigDecimal amount;
|
||||
|
||||
@NotBlank(message = "통화는 필수입니다")
|
||||
@Size(min = 3, max = 3, message = "통화 코드는 3자리입니다")
|
||||
private String currency;
|
||||
|
||||
public AcquisitionRequest() {}
|
||||
public AcquisitionRequest(String merchantId, String cardNumber, BigDecimal amount, String currency) {
|
||||
this.merchantId = merchantId;
|
||||
this.cardNumber = cardNumber;
|
||||
this.amount = amount;
|
||||
this.currency = currency;
|
||||
}
|
||||
|
||||
public String getMerchantId() { return merchantId; }
|
||||
public void setMerchantId(String merchantId) { this.merchantId = merchantId; }
|
||||
public String getCardNumber() { return cardNumber; }
|
||||
public void setCardNumber(String cardNumber) { this.cardNumber = cardNumber; }
|
||||
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; }
|
||||
}
|
||||
|
||||
class AcquisitionResponse {
|
||||
private Long id;
|
||||
private String merchantId;
|
||||
private String maskedCardNumber;
|
||||
private BigDecimal amount;
|
||||
private String currency;
|
||||
private CardAcquisition.AcquisitionStatus status;
|
||||
private String approvalCode;
|
||||
private LocalDateTime requestTime;
|
||||
private LocalDateTime approvalTime;
|
||||
private String message;
|
||||
|
||||
public AcquisitionResponse() {}
|
||||
|
||||
public static AcquisitionResponse fromEntity(CardAcquisition entity) {
|
||||
AcquisitionResponse response = new AcquisitionResponse();
|
||||
response.setId(entity.getId());
|
||||
response.setMerchantId(entity.getMerchantId());
|
||||
response.setMaskedCardNumber(maskCardNumber(entity.getCardNumber()));
|
||||
response.setAmount(entity.getAmount());
|
||||
response.setCurrency(entity.getCurrency());
|
||||
response.setStatus(entity.getStatus());
|
||||
response.setApprovalCode(entity.getApprovalCode());
|
||||
response.setRequestTime(entity.getRequestTime());
|
||||
response.setApprovalTime(entity.getApprovalTime());
|
||||
return response;
|
||||
}
|
||||
|
||||
private static String maskCardNumber(String cardNumber) {
|
||||
if (cardNumber == null || cardNumber.length() < 8) return "****";
|
||||
return cardNumber.substring(0, 4) + "****" + cardNumber.substring(cardNumber.length() - 4);
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getMerchantId() { return merchantId; }
|
||||
public void setMerchantId(String merchantId) { this.merchantId = merchantId; }
|
||||
public String getMaskedCardNumber() { return maskedCardNumber; }
|
||||
public void setMaskedCardNumber(String maskedCardNumber) { this.maskedCardNumber = maskedCardNumber; }
|
||||
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 CardAcquisition.AcquisitionStatus getStatus() { return status; }
|
||||
public void setStatus(CardAcquisition.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 getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.acquirex.cardacquisition.repository;
|
||||
|
||||
import com.acquirex.cardacquisition.entity.CardAcquisition;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface CardAcquisitionRepository extends JpaRepository<CardAcquisition, Long> {
|
||||
|
||||
List<CardAcquisition> findByMerchantId(String merchantId);
|
||||
|
||||
List<CardAcquisition> findByStatus(CardAcquisition.AcquisitionStatus status);
|
||||
|
||||
Optional<CardAcquisition> findByApprovalCode(String approvalCode);
|
||||
|
||||
List<CardAcquisition> findByRequestTimeBetween(LocalDateTime start, LocalDateTime end);
|
||||
|
||||
List<CardAcquisition> findByMerchantIdAndStatus(String merchantId, CardAcquisition.AcquisitionStatus status);
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package com.acquirex.cardacquisition.service;
|
||||
|
||||
import com.acquirex.cardacquisition.entity.CardAcquisition;
|
||||
import com.acquirex.cardacquisition.repository.CardAcquisitionRepository;
|
||||
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.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class CardAcquisitionService {
|
||||
|
||||
private static final BigDecimal MAX_SINGLE_TRANSACTION = new BigDecimal("10000000");
|
||||
private static final BigDecimal DAILY_LIMIT = new BigDecimal("50000000");
|
||||
|
||||
private final CardAcquisitionRepository repository;
|
||||
|
||||
public CardAcquisitionService(CardAcquisitionRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcquisitionResponse requestAcquisition(AcquisitionRequest request) {
|
||||
validateRequest(request);
|
||||
|
||||
CardAcquisition acquisition = new CardAcquisition(
|
||||
request.getMerchantId(), request.getCardNumber(), request.getAmount(), request.getCurrency());
|
||||
|
||||
CardAcquisition saved = repository.save(acquisition);
|
||||
String approvalCode = generateApprovalCode();
|
||||
saved.approve(approvalCode);
|
||||
|
||||
AcquisitionResponse response = AcquisitionResponse.fromEntity(repository.save(saved));
|
||||
response.setMessage("매입 승인이 완료되었습니다");
|
||||
return response;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AcquisitionResponse getAcquisition(Long id) {
|
||||
CardAcquisition acquisition = repository.findById(id)
|
||||
.orElseThrow(() -> new AcquisitionNotFoundException("매입 정보를 찾을 수 없습니다: " + id));
|
||||
AcquisitionResponse response = AcquisitionResponse.fromEntity(acquisition);
|
||||
response.setMessage("매입 정보를 조회했습니다");
|
||||
return response;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<AcquisitionResponse> getAcquisitionsByMerchant(String merchantId) {
|
||||
return repository.findByMerchantId(merchantId).stream()
|
||||
.map(AcquisitionResponse::fromEntity).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<AcquisitionResponse> getPendingAcquisitions() {
|
||||
return repository.findByStatus(CardAcquisition.AcquisitionStatus.PENDING).stream()
|
||||
.map(AcquisitionResponse::fromEntity).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcquisitionResponse cancelAcquisition(Long id) {
|
||||
CardAcquisition acquisition = repository.findById(id)
|
||||
.orElseThrow(() -> new AcquisitionNotFoundException("매입 정보를 찾을 수 없습니다: " + id));
|
||||
|
||||
if (acquisition.getStatus() == CardAcquisition.AcquisitionStatus.CANCELLED) {
|
||||
throw new IllegalStateException("이미 취소된 매입입니다");
|
||||
}
|
||||
|
||||
acquisition.setStatus(CardAcquisition.AcquisitionStatus.CANCELLED);
|
||||
acquisition.setApprovalTime(LocalDateTime.now());
|
||||
|
||||
AcquisitionResponse response = AcquisitionResponse.fromEntity(repository.save(acquisition));
|
||||
response.setMessage("매입이 취소되었습니다");
|
||||
return response;
|
||||
}
|
||||
|
||||
private void validateRequest(AcquisitionRequest request) {
|
||||
if (request.getAmount().compareTo(MAX_SINGLE_TRANSACTION) > 0) {
|
||||
throw new InvalidAcquisitionException("단일 거래 한도를 초과했습니다");
|
||||
}
|
||||
|
||||
LocalDateTime startOfDay = LocalDateTime.now().toLocalDate().atStartOfDay();
|
||||
BigDecimal todayTotal = repository.findByRequestTimeBetween(startOfDay, startOfDay.plusDays(1)).stream()
|
||||
.filter(a -> a.getMerchantId().equals(request.getMerchantId()))
|
||||
.map(CardAcquisition::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
if (todayTotal.add(request.getAmount()).compareTo(DAILY_LIMIT) > 0) {
|
||||
throw new InvalidAcquisitionException("일일 한도를 초과했습니다");
|
||||
}
|
||||
}
|
||||
|
||||
private String generateApprovalCode() {
|
||||
return "APR" + UUID.randomUUID().toString().substring(0, 12).toUpperCase();
|
||||
}
|
||||
|
||||
public static class AcquisitionNotFoundException extends RuntimeException {
|
||||
public AcquisitionNotFoundException(String message) { super(message); }
|
||||
}
|
||||
|
||||
public static class InvalidAcquisitionException extends RuntimeException {
|
||||
public InvalidAcquisitionException(String message) { super(message); }
|
||||
}
|
||||
}
|
||||
24
src/main/resources/application.properties
Normal file
24
src/main/resources/application.properties
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
spring.application.name=card-acquisition
|
||||
|
||||
# H2 Database
|
||||
spring.datasource.url=jdbc:h2:mem:cardacquisition;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||
spring.datasource.driverClassName=org.h2.Driver
|
||||
spring.datasource.username=sa
|
||||
spring.datasource.password=
|
||||
|
||||
# JPA/Hibernate 6 (Spring Boot 3)
|
||||
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
|
||||
spring.jpa.hibernate.ddl-auto=create-drop
|
||||
spring.jpa.show-sql=true
|
||||
spring.jpa.properties.hibernate.format_sql=true
|
||||
|
||||
# H2 Console
|
||||
spring.h2.console.enabled=true
|
||||
spring.h2.console.path=/h2-console
|
||||
|
||||
# Server
|
||||
server.port=8080
|
||||
|
||||
# Jackson
|
||||
spring.jackson.serialization.write-dates-as-timestamps=false
|
||||
spring.jackson.date-format=yyyy-MM-dd'T'HH:mm:ss
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
spring:
|
||||
application:
|
||||
name: proj-acquirex
|
||||
datasource:
|
||||
url: jdbc:h2:mem:acquirex;DB_CLOSE_DELAY=-1
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
h2:
|
||||
console:
|
||||
enabled: true
|
||||
path: /h2-console
|
||||
server:
|
||||
port: 8080
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.acquirex.cardacquisition;
|
||||
|
||||
import com.acquirex.cardacquisition.entity.CardAcquisition;
|
||||
import com.acquirex.cardacquisition.repository.CardAcquisitionRepository;
|
||||
import com.acquirex.cardacquisition.service.CardAcquisitionService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
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
|
||||
void requestAcquisition_ValidRequest_ReturnsApprovedResponse() {
|
||||
when(repository.save(any(CardAcquisition.class))).thenAnswer(i -> {
|
||||
CardAcquisition e = i.getArgument(0);
|
||||
if (e.getId() == null) e.setId(1L);
|
||||
return e;
|
||||
});
|
||||
when(repository.findByRequestTimeBetween(any(), any())).thenReturn(List.of());
|
||||
|
||||
AcquisitionRequest request = new AcquisitionRequest("MERCHANT001", "1234567890123456", new BigDecimal("50000"), "KRW");
|
||||
AcquisitionResponse response = service.requestAcquisition(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(1L, response.getId());
|
||||
assertEquals(CardAcquisition.AcquisitionStatus.APPROVED, response.getStatus());
|
||||
assertTrue(response.getApprovalCode().startsWith("APR"));
|
||||
verify(repository, times(2)).save(any(CardAcquisition.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestAcquisition_ExceedsLimit_ThrowsException() {
|
||||
AcquisitionRequest request = new AcquisitionRequest("MERCHANT001", "1234567890123456", new BigDecimal("15000000"), "KRW");
|
||||
assertThrows(CardAcquisitionService.InvalidAcquisitionException.class, () -> service.requestAcquisition(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAcquisition_ExistingId_ReturnsResponse() {
|
||||
CardAcquisition entity = new CardAcquisition("M1", "1111", new BigDecimal("1000"), "KRW");
|
||||
entity.setId(1L);
|
||||
entity.approve("APR123");
|
||||
when(repository.findById(1L)).thenReturn(Optional.of(entity));
|
||||
|
||||
AcquisitionResponse response = service.getAcquisition(1L);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(CardAcquisition.AcquisitionStatus.APPROVED, response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAcquisition_NotFound_ThrowsException() {
|
||||
when(repository.findById(999L)).thenReturn(Optional.empty());
|
||||
assertThrows(CardAcquisitionService.AcquisitionNotFoundException.class, () -> service.getAcquisition(999L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelAcquisition_ExistingPending_ReturnsCancelled() {
|
||||
CardAcquisition entity = new CardAcquisition("M1", "1111", new BigDecimal("1000"), "KRW");
|
||||
entity.setId(1L);
|
||||
when(repository.findById(1L)).thenReturn(Optional.of(entity));
|
||||
when(repository.save(any())).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
AcquisitionResponse response = service.cancelAcquisition(1L);
|
||||
|
||||
assertEquals(CardAcquisition.AcquisitionStatus.CANCELLED, response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelAcquisition_AlreadyCancelled_ThrowsException() {
|
||||
CardAcquisition entity = new CardAcquisition("M1", "1111", new BigDecimal("1000"), "KRW");
|
||||
entity.setId(1L);
|
||||
entity.setStatus(CardAcquisition.AcquisitionStatus.CANCELLED);
|
||||
when(repository.findById(1L)).thenReturn(Optional.of(entity));
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.cancelAcquisition(1L));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue