diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..cbac232 --- /dev/null +++ b/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.acquirex + proj-acquirex + 1.0.0-SNAPSHOT + proj-acquirex + 카드 매입 승인 시스템 - Spring Boot 3 + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/src/main/java/com/acquirex/AcquirexApplication.java b/src/main/java/com/acquirex/AcquirexApplication.java new file mode 100644 index 0000000..dedeed3 --- /dev/null +++ b/src/main/java/com/acquirex/AcquirexApplication.java @@ -0,0 +1,12 @@ +package com.acquirex; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AcquirexApplication { + + public static void main(String[] args) { + SpringApplication.run(AcquirexApplication.class, args); + } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/controller/CardAcquisitionController.java b/src/main/java/com/acquirex/controller/CardAcquisitionController.java new file mode 100644 index 0000000..92eec72 --- /dev/null +++ b/src/main/java/com/acquirex/controller/CardAcquisitionController.java @@ -0,0 +1,44 @@ +package com.acquirex.controller; + +import com.acquirex.dto.AcquisitionDto.Request; +import com.acquirex.dto.AcquisitionDto.Response; +import com.acquirex.entity.CardAcquisition; +import com.acquirex.service.CardAcquisitionService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 카드 매입 승인 REST API 컨트롤러 + * Spring Boot 3: @RestController, @RequestMapping 유지 + */ +@RestController +@RequestMapping("/api/v1/acquisitions") +public class CardAcquisitionController { + + private final CardAcquisitionService acquisitionService; + + public CardAcquisitionController(CardAcquisitionService acquisitionService) { + this.acquisitionService = acquisitionService; + } + + @PostMapping + public ResponseEntity requestAcquisition(@Valid @RequestBody Request request) { + Response response = acquisitionService.processAcquisition(request); + if ("APPROVED".equals(response.getStatus())) { + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @GetMapping("/{id}") + public ResponseEntity getAcquisition(@PathVariable Long id) { + return ResponseEntity.ok(acquisitionService.findById(id)); + } + + @GetMapping("/approval/{approvalCode}") + public ResponseEntity getByApprovalCode(@PathVariable String approvalCode) { + return ResponseEntity.ok(acquisitionService.findByApprovalCode(approvalCode)); + } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/dto/AcquisitionDto.java b/src/main/java/com/acquirex/dto/AcquisitionDto.java new file mode 100644 index 0000000..4cf27a8 --- /dev/null +++ b/src/main/java/com/acquirex/dto/AcquisitionDto.java @@ -0,0 +1,88 @@ +package com.acquirex.dto; + +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 카드 매입 요청/응답 DTO + * Spring Boot 3: javax.validation → jakarta.validation + */ +public class AcquisitionDto { + + // ========== Request DTO ========== + public static class Request { + @NotBlank(message = "카드번호는 필수입니다") + @Size(min = 13, max = 19, message = "카드번호 길이가 올바르지 않습니다") + private String cardNumber; + + @NotBlank(message = "가맹점ID는 필수입니다") + private String merchantId; + + @NotNull(message = "금액은 필수입니다") + @DecimalMin(value = "0.01", message = "금액은 0보다 커야 합니다") + @Digits(integer = 13, fraction = 2, message = "금액 형식이 올바르지 않습니다") + private BigDecimal amount; + + @NotBlank(message = "통화는 필수입니다") + @Size(min = 3, max = 3, message = "통화 코드는 3자리입니다") + private String currency; + + public String getCardNumber() { return cardNumber; } + public void setCardNumber(String cardNumber) { this.cardNumber = cardNumber; } + public String getMerchantId() { return merchantId; } + public void setMerchantId(String merchantId) { this.merchantId = merchantId; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + } + + // ========== Response DTO ========== + public static class Response { + private Long id; + private String approvalCode; + private String status; + private String message; + private BigDecimal amount; + private String currency; + private LocalDateTime approvalTime; + + public Response() {} + + public static Response success(Long id, String approvalCode, BigDecimal amount, String currency) { + Response response = new Response(); + response.setId(id); + response.setApprovalCode(approvalCode); + response.setStatus("APPROVED"); + response.setMessage("매입 승인이 완료되었습니다"); + response.setAmount(amount); + response.setCurrency(currency); + response.setApprovalTime(LocalDateTime.now()); + return response; + } + + public static Response rejected(Long id, String reason) { + Response response = new Response(); + response.setId(id); + response.setStatus("REJECTED"); + response.setMessage(reason); + return response; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getApprovalCode() { return approvalCode; } + public void setApprovalCode(String approvalCode) { this.approvalCode = approvalCode; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + public LocalDateTime getApprovalTime() { return approvalTime; } + public void setApprovalTime(LocalDateTime approvalTime) { this.approvalTime = approvalTime; } + } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/entity/CardAcquisition.java b/src/main/java/com/acquirex/entity/CardAcquisition.java new file mode 100644 index 0000000..c3e8507 --- /dev/null +++ b/src/main/java/com/acquirex/entity/CardAcquisition.java @@ -0,0 +1,89 @@ +package com.acquirex.entity; + +import jakarta.persistence.*; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 카드 매입 승인 엔티티 및 리포지토리 + * Spring Boot 3: javax.persistence → jakarta.persistence + */ +@Entity +@Table(name = "card_acquisitions") +public class CardAcquisition { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "card_number", nullable = false, length = 16) + private String cardNumber; + + @Column(name = "merchant_id", nullable = false) + private String merchantId; + + @Column(name = "amount", nullable = false, precision = 15, scale = 2) + private BigDecimal amount; + + @Column(name = "currency", nullable = false, length = 3) + private String currency; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private AcquisitionStatus status; + + @Column(name = "approval_code", length = 12) + private String approvalCode; + + @Column(name = "request_time", nullable = false) + private LocalDateTime requestTime; + + @Column(name = "approval_time") + private LocalDateTime approvalTime; + + @Column(name = "reject_reason", length = 255) + private String rejectReason; + + public enum AcquisitionStatus { + PENDING, APPROVED, REJECTED, CANCELLED + } + + public CardAcquisition() { + this.requestTime = LocalDateTime.now(); + this.status = AcquisitionStatus.PENDING; + } + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getCardNumber() { return cardNumber; } + public void setCardNumber(String cardNumber) { this.cardNumber = cardNumber; } + public String getMerchantId() { return merchantId; } + public void setMerchantId(String merchantId) { this.merchantId = merchantId; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + public AcquisitionStatus getStatus() { return status; } + public void setStatus(AcquisitionStatus status) { this.status = status; } + public String getApprovalCode() { return approvalCode; } + public void setApprovalCode(String approvalCode) { this.approvalCode = approvalCode; } + public LocalDateTime getRequestTime() { return requestTime; } + public void setRequestTime(LocalDateTime requestTime) { this.requestTime = requestTime; } + public LocalDateTime getApprovalTime() { return approvalTime; } + public void setApprovalTime(LocalDateTime approvalTime) { this.approvalTime = approvalTime; } + public String getRejectReason() { return rejectReason; } + public void setRejectReason(String rejectReason) { this.rejectReason = rejectReason; } + + @Repository + public interface CardAcquisitionRepository extends JpaRepository { + List findByStatus(AcquisitionStatus status); + Optional findByApprovalCode(String approvalCode); + List findByMerchantId(String merchantId); + } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/service/CardAcquisitionService.java b/src/main/java/com/acquirex/service/CardAcquisitionService.java new file mode 100644 index 0000000..0d8493f --- /dev/null +++ b/src/main/java/com/acquirex/service/CardAcquisitionService.java @@ -0,0 +1,116 @@ +package com.acquirex.service; + +import com.acquirex.dto.AcquisitionDto.Request; +import com.acquirex.dto.AcquisitionDto.Response; +import com.acquirex.entity.CardAcquisition; +import com.acquirex.entity.CardAcquisition.AcquisitionStatus; +import com.acquirex.entity.CardAcquisition.CardAcquisitionRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Random; + +@Service +public class CardAcquisitionService { + + private static final BigDecimal MAX_APPROVAL_AMOUNT = new BigDecimal("10000000"); + private static final BigDecimal SUSPICIOUS_AMOUNT_THRESHOLD = new BigDecimal("5000000"); + + private final CardAcquisitionRepository repository; + private final Random approvalCodeGenerator = new Random(); + + public CardAcquisitionService(CardAcquisitionRepository repository) { + this.repository = repository; + } + + @Transactional + public Response processAcquisition(Request request) { + CardAcquisition acquisition = createAcquisition(request); + CardAcquisition saved = repository.save(acquisition); + + if (!validateCardNumber(request.getCardNumber())) { + return rejectAcquisition(saved, "유효하지 않은 카드번호입니다"); + } + + if (request.getAmount().compareTo(MAX_APPROVAL_AMOUNT) > 0) { + return rejectAcquisition(saved, "승인 한도를 초과했습니다"); + } + + if (isSuspiciousTransaction(request)) { + return rejectAcquisition(saved, "의심스러운 거래로 거부되었습니다"); + } + + return approveAcquisition(saved); + } + + private CardAcquisition createAcquisition(Request request) { + CardAcquisition acquisition = new CardAcquisition(); + acquisition.setCardNumber(maskCardNumber(request.getCardNumber())); + acquisition.setMerchantId(request.getMerchantId()); + acquisition.setAmount(request.getAmount()); + acquisition.setCurrency(request.getCurrency()); + acquisition.setStatus(AcquisitionStatus.PENDING); + return acquisition; + } + + private boolean validateCardNumber(String cardNumber) { + if (cardNumber == null || cardNumber.length() < 13) return false; + int sum = 0; + boolean alternate = false; + for (int i = cardNumber.length() - 1; i >= 0; i--) { + int digit = Character.getNumericValue(cardNumber.charAt(i)); + if (alternate) { + digit *= 2; + if (digit > 9) digit -= 9; + } + sum += digit; + alternate = !alternate; + } + return sum % 10 == 0; + } + + private boolean isSuspiciousTransaction(Request request) { + return request.getAmount().compareTo(SUSPICIOUS_AMOUNT_THRESHOLD) >= 0 + && request.getCardNumber().startsWith("9999"); + } + + private String generateApprovalCode() { + return String.format("%06d", approvalCodeGenerator.nextInt(999999)); + } + + private String maskCardNumber(String cardNumber) { + if (cardNumber == null || cardNumber.length() < 8) return cardNumber; + return cardNumber.substring(0, 4) + "****" + cardNumber.substring(cardNumber.length() - 4); + } + + private Response approveAcquisition(CardAcquisition acquisition) { + String approvalCode = generateApprovalCode(); + acquisition.setStatus(AcquisitionStatus.APPROVED); + acquisition.setApprovalCode(approvalCode); + acquisition.setApprovalTime(LocalDateTime.now()); + repository.save(acquisition); + return Response.success(acquisition.getId(), approvalCode, acquisition.getAmount(), acquisition.getCurrency()); + } + + private Response rejectAcquisition(CardAcquisition acquisition, String reason) { + acquisition.setStatus(AcquisitionStatus.REJECTED); + acquisition.setRejectReason(reason); + acquisition.setApprovalTime(LocalDateTime.now()); + repository.save(acquisition); + return Response.rejected(acquisition.getId(), reason); + } + + @Transactional(readOnly = true) + public CardAcquisition findById(Long id) { + return repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("매입 정보를 찾을 수 없습니다: " + id)); + } + + @Transactional(readOnly = true) + public CardAcquisition findByApprovalCode(String approvalCode) { + return repository.findByApprovalCode(approvalCode) + .orElseThrow(() -> new IllegalArgumentException("승인코드를 찾을 수 없습니다: " + approvalCode)); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..643ac40 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,31 @@ +spring: + application: + name: proj-acquirex + + datasource: + url: jdbc:h2:mem:acquirexdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + + h2: + console: + enabled: true + path: /h2-console + +server: + port: 8080 + +logging: + level: + com.acquirex: DEBUG + org.hibernate.SQL: DEBUG \ No newline at end of file diff --git a/src/test/java/com/acquirex/service/CardAcquisitionServiceTest.java b/src/test/java/com/acquirex/service/CardAcquisitionServiceTest.java new file mode 100644 index 0000000..b77412e --- /dev/null +++ b/src/test/java/com/acquirex/service/CardAcquisitionServiceTest.java @@ -0,0 +1,145 @@ +package com.acquirex.service; + +import com.acquirex.dto.AcquisitionDto.Request; +import com.acquirex.dto.AcquisitionDto.Response; +import com.acquirex.entity.CardAcquisition; +import com.acquirex.entity.CardAcquisition.CardAcquisitionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CardAcquisitionServiceTest { + + @Mock + private CardAcquisitionRepository repository; + + private CardAcquisitionService service; + + @BeforeEach + void setUp() { + service = new CardAcquisitionService(repository); + } + + @Test + @DisplayName("유효한 카드로 정상 승인 처리") + void processAcquisition_ValidCard_ShouldApprove() { + Request request = new Request(); + request.setCardNumber("4532015112830366"); + request.setMerchantId("MERCHANT001"); + request.setAmount(new BigDecimal("100000")); + request.setCurrency("KRW"); + + when(repository.save(any(CardAcquisition.class))) + .thenAnswer(invocation -> { + CardAcquisition acq = invocation.getArgument(0); + acq.setId(1L); + return acq; + }); + + Response response = service.processAcquisition(request); + + assertEquals("APPROVED", response.getStatus()); + assertNotNull(response.getApprovalCode()); + assertEquals(6, response.getApprovalCode().length()); + assertEquals(new BigDecimal("100000"), response.getAmount()); + assertEquals("KRW", response.getCurrency()); + } + + @Test + @DisplayName("유효하지 않은 카드번호 거부") + void processAcquisition_InvalidCardNumber_ShouldReject() { + Request request = new Request(); + request.setCardNumber("1234567890123456"); + request.setMerchantId("MERCHANT001"); + request.setAmount(new BigDecimal("50000")); + request.setCurrency("KRW"); + + when(repository.save(any(CardAcquisition.class))) + .thenAnswer(invocation -> { + CardAcquisition acq = invocation.getArgument(0); + acq.setId(1L); + return acq; + }); + + Response response = service.processAcquisition(request); + + assertEquals("REJECTED", response.getStatus()); + assertNull(response.getApprovalCode()); + assertTrue(response.getMessage().contains("유효하지 않은")); + } + + @Test + @DisplayName("한도 초과 거래 거부") + void processAcquisition_ExceedLimit_ShouldReject() { + Request request = new Request(); + request.setCardNumber("4532015112830366"); + request.setMerchantId("MERCHANT001"); + request.setAmount(new BigDecimal("15000000")); + request.setCurrency("KRW"); + + when(repository.save(any(CardAcquisition.class))) + .thenAnswer(invocation -> { + CardAcquisition acq = invocation.getArgument(0); + acq.setId(1L); + return acq; + }); + + Response response = service.processAcquisition(request); + + assertEquals("REJECTED", response.getStatus()); + assertTrue(response.getMessage().contains("한도")); + } + + @Test + @DisplayName("의심스러운 거래 거부 (9999 시작 + 고액)") + void processAcquisition_SuspiciousTransaction_ShouldReject() { + Request request = new Request(); + request.setCardNumber("9999123456789012"); + request.setMerchantId("MERCHANT001"); + request.setAmount(new BigDecimal("6000000")); + request.setCurrency("KRW"); + + when(repository.save(any(CardAcquisition.class))) + .thenAnswer(invocation -> { + CardAcquisition acq = invocation.getArgument(0); + acq.setId(1L); + return acq; + }); + + Response response = service.processAcquisition(request); + + assertEquals("REJECTED", response.getStatus()); + assertTrue(response.getMessage().contains("의심스러운")); + } + + @Test + @DisplayName("카드번호 마스킹 처리 확인") + void processAcquisition_CardNumberMasked() { + Request request = new Request(); + request.setCardNumber("4532015112830366"); + request.setMerchantId("MERCHANT001"); + request.setAmount(new BigDecimal("100000")); + request.setCurrency("KRW"); + + when(repository.save(any(CardAcquisition.class))) + .thenAnswer(invocation -> { + CardAcquisition acq = invocation.getArgument(0); + acq.setId(1L); + return acq; + }); + + service.processAcquisition(request); + + verify(repository, times(2)).save(argThat(acq -> acq.getCardNumber().equals("4532****0366"))); + } +} \ No newline at end of file