Compare commits

...
Sign in to create a new pull request.

8 commits

8 changed files with 656 additions and 0 deletions

57
pom.xml Normal file
View file

@ -0,0 +1,57 @@
<?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">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.acquirex</groupId>
<artifactId>proj-acquirex</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>proj-acquirex</name>
<description>Card Acquisition Approval System - Spring Boot 3</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View file

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

View file

@ -0,0 +1,191 @@
package com.acquirex.controller;
import com.acquirex.dto.ApprovalRequest;
import com.acquirex.dto.ApprovalResponse;
import com.acquirex.exception.ApprovalProcessingException;
import com.acquirex.exception.GlobalExceptionHandler;
import com.acquirex.service.AcquisitionApprovalService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/acquisitions")
public class AcquisitionApprovalController {
private final AcquisitionApprovalService service;
public AcquisitionApprovalController(AcquisitionApprovalService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<ApprovalResponse> requestApproval(@Valid @RequestBody ApprovalRequest request) {
ApprovalResponse response = service.requestApproval(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PostMapping("/{id}/approve")
public ResponseEntity<ApprovalResponse> approve(@PathVariable Long id) {
ApprovalResponse response = service.processApproval(id, true, null);
return ResponseEntity.ok(response);
}
@PostMapping("/{id}/reject")
public ResponseEntity<ApprovalResponse> reject(@PathVariable Long id, @RequestParam String reason) {
ApprovalResponse response = service.processApproval(id, false, reason);
return ResponseEntity.ok(response);
}
@GetMapping("/{id}")
public ResponseEntity<ApprovalResponse> getApproval(@PathVariable Long id) {
ApprovalResponse response = service.getApproval(id);
return ResponseEntity.ok(response);
}
@GetMapping("/pending")
public ResponseEntity<List<ApprovalResponse>> getPendingApprovals() {
List<ApprovalResponse> responses = service.getPendingApprovals();
return ResponseEntity.ok(responses);
}
@GetMapping("/merchant/{merchantId}")
public ResponseEntity<List<ApprovalResponse>> getApprovalsByMerchant(@PathVariable String merchantId) {
List<ApprovalResponse> responses = service.getApprovalsByMerchant(merchantId);
return ResponseEntity.ok(responses);
}
}
// DTOs
class ApprovalRequest {
@jakarta.validation.constraints.NotBlank(message = "Merchant ID is required")
private String merchantId;
@jakarta.validation.constraints.NotBlank(message = "Card number is required")
@jakarta.validation.constraints.Size(min = 13, max = 19, message = "Card number must be between 13 and 19 digits")
private String cardNumber;
@jakarta.validation.constraints.NotNull(message = "Amount is required")
@jakarta.validation.constraints.DecimalMin(value = "0.01", message = "Amount must be greater than zero")
private BigDecimal amount;
@jakarta.validation.constraints.NotBlank(message = "Currency is required")
@jakarta.validation.constraints.Size(min = 3, max = 3, message = "Currency must be a 3-letter code")
private String currency;
public ApprovalRequest() {}
public ApprovalRequest(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 ApprovalResponse {
private Long id;
private String merchantId;
private String cardNumber;
private BigDecimal amount;
private String currency;
private String status;
private String approvalCode;
private String rejectionReason;
private LocalDateTime requestedAt;
private LocalDateTime processedAt;
public ApprovalResponse() {}
public ApprovalResponse(Long id, String merchantId, String cardNumber, BigDecimal amount,
String currency, String status, String approvalCode,
String rejectionReason, LocalDateTime requestedAt, LocalDateTime processedAt) {
this.id = id;
this.merchantId = merchantId;
this.cardNumber = cardNumber;
this.amount = amount;
this.currency = currency;
this.status = status;
this.approvalCode = approvalCode;
this.rejectionReason = rejectionReason;
this.requestedAt = requestedAt;
this.processedAt = processedAt;
}
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 String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getApprovalCode() { return approvalCode; }
public void setApprovalCode(String approvalCode) { this.approvalCode = approvalCode; }
public String getRejectionReason() { return rejectionReason; }
public void setRejectionReason(String rejectionReason) { this.rejectionReason = rejectionReason; }
public LocalDateTime getRequestedAt() { return requestedAt; }
public void setRequestedAt(LocalDateTime requestedAt) { this.requestedAt = requestedAt; }
public LocalDateTime getProcessedAt() { return processedAt; }
public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; }
}
// Exceptions
class ApprovalProcessingException extends RuntimeException {
public ApprovalProcessingException(String message) {
super(message);
}
}
@RestControllerAdvice
class GlobalExceptionHandler {
@org.springframework.web.bind.annotation.ExceptionHandler(ApprovalProcessingException.class)
public ResponseEntity<Map<String, Object>> handleApprovalProcessingException(ApprovalProcessingException ex) {
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("status", HttpStatus.BAD_REQUEST.value());
body.put("error", "Approval Processing Error");
body.put("message", ex.getMessage());
return ResponseEntity.badRequest().body(body);
}
@org.springframework.web.bind.annotation.ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("status", HttpStatus.BAD_REQUEST.value());
body.put("error", "Validation Error");
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
body.put("errors", errors);
return ResponseEntity.badRequest().body(body);
}
}

View file

@ -0,0 +1,72 @@
package com.acquirex.domain;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "acquisition_approvals")
public class AcquisitionApproval {
@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 ApprovalStatus status;
@Column(name = "approval_code")
private String approvalCode;
@Column(name = "rejection_reason")
private String rejectionReason;
@Column(name = "requested_at", nullable = false)
private LocalDateTime requestedAt;
@Column(name = "processed_at")
private LocalDateTime processedAt;
public enum ApprovalStatus {
PENDING, APPROVED, REJECTED, CANCELLED
}
public AcquisitionApproval() {
this.requestedAt = LocalDateTime.now();
this.status = ApprovalStatus.PENDING;
}
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 ApprovalStatus getStatus() { return status; }
public void setStatus(ApprovalStatus status) { this.status = status; }
public String getApprovalCode() { return approvalCode; }
public void setApprovalCode(String approvalCode) { this.approvalCode = approvalCode; }
public String getRejectionReason() { return rejectionReason; }
public void setRejectionReason(String rejectionReason) { this.rejectionReason = rejectionReason; }
public LocalDateTime getRequestedAt() { return requestedAt; }
public void setRequestedAt(LocalDateTime requestedAt) { this.requestedAt = requestedAt; }
public LocalDateTime getProcessedAt() { return processedAt; }
public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; }
}

View file

@ -0,0 +1,24 @@
package com.acquirex.repository;
import com.acquirex.domain.AcquisitionApproval;
import com.acquirex.domain.AcquisitionApproval.ApprovalStatus;
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 AcquisitionApprovalRepository extends JpaRepository<AcquisitionApproval, Long> {
List<AcquisitionApproval> findByMerchantId(String merchantId);
List<AcquisitionApproval> findByStatus(ApprovalStatus status);
Optional<AcquisitionApproval> findByApprovalCode(String approvalCode);
List<AcquisitionApproval> findByMerchantIdAndStatus(String merchantId, ApprovalStatus status);
List<AcquisitionApproval> findByRequestedAtBetween(LocalDateTime start, LocalDateTime end);
}

View file

@ -0,0 +1,125 @@
package com.acquirex.service;
import com.acquirex.domain.AcquisitionApproval;
import com.acquirex.domain.AcquisitionApproval.ApprovalStatus;
import com.acquirex.dto.ApprovalRequest;
import com.acquirex.dto.ApprovalResponse;
import com.acquirex.exception.ApprovalProcessingException;
import com.acquirex.repository.AcquisitionApprovalRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
public class AcquisitionApprovalService {
private final AcquisitionApprovalRepository repository;
public AcquisitionApprovalService(AcquisitionApprovalRepository repository) {
this.repository = repository;
}
@Transactional
public ApprovalResponse requestApproval(ApprovalRequest request) {
validateRequest(request);
AcquisitionApproval approval = new AcquisitionApproval();
approval.setMerchantId(request.getMerchantId());
approval.setCardNumber(maskCardNumber(request.getCardNumber()));
approval.setAmount(request.getAmount());
approval.setCurrency(request.getCurrency());
AcquisitionApproval saved = repository.save(approval);
return toResponse(saved);
}
@Transactional
public ApprovalResponse processApproval(Long id, boolean approved, String reason) {
AcquisitionApproval approval = repository.findById(id)
.orElseThrow(() -> new ApprovalProcessingException("Approval not found: " + id));
if (approval.getStatus() != ApprovalStatus.PENDING) {
throw new ApprovalProcessingException("Approval already processed");
}
if (approved) {
approval.setStatus(ApprovalStatus.APPROVED);
approval.setApprovalCode(generateApprovalCode());
} else {
approval.setStatus(ApprovalStatus.REJECTED);
approval.setRejectionReason(reason);
}
approval.setProcessedAt(LocalDateTime.now());
AcquisitionApproval saved = repository.save(approval);
return toResponse(saved);
}
@Transactional(readOnly = true)
public ApprovalResponse getApproval(Long id) {
return repository.findById(id)
.map(this::toResponse)
.orElseThrow(() -> new ApprovalProcessingException("Approval not found: " + id));
}
@Transactional(readOnly = true)
public List<ApprovalResponse> getPendingApprovals() {
return repository.findByStatus(ApprovalStatus.PENDING)
.stream()
.map(this::toResponse)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<ApprovalResponse> getApprovalsByMerchant(String merchantId) {
return repository.findByMerchantId(merchantId)
.stream()
.map(this::toResponse)
.collect(Collectors.toList());
}
private void validateRequest(ApprovalRequest request) {
if (request.getMerchantId() == null || request.getMerchantId().isBlank()) {
throw new ApprovalProcessingException("Merchant ID is required");
}
if (request.getCardNumber() == null || request.getCardNumber().length() < 13) {
throw new ApprovalProcessingException("Valid card number is required");
}
if (request.getAmount() == null || request.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0) {
throw new ApprovalProcessingException("Amount must be positive");
}
if (request.getCurrency() == null || request.getCurrency().length() != 3) {
throw new ApprovalProcessingException("Valid currency code is required");
}
}
private String maskCardNumber(String cardNumber) {
if (cardNumber == null || cardNumber.length() < 4) {
return "****";
}
return "****-****-****-" + cardNumber.substring(cardNumber.length() - 4);
}
private String generateApprovalCode() {
return "APP-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
}
private ApprovalResponse toResponse(AcquisitionApproval approval) {
return new ApprovalResponse(
approval.getId(),
approval.getMerchantId(),
approval.getCardNumber(),
approval.getAmount(),
approval.getCurrency(),
approval.getStatus().name(),
approval.getApprovalCode(),
approval.getRejectionReason(),
approval.getRequestedAt(),
approval.getProcessedAt()
);
}
}

View file

@ -0,0 +1,20 @@
spring.application.name=proj-acquirex
# H2 Database Configuration
spring.datasource.url=jdbc:h2:mem:acquirexdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# JPA/Hibernate Configuration
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 Configuration
server.port=8080

View file

@ -0,0 +1,155 @@
package com.acquirex.service;
import com.acquirex.domain.AcquisitionApproval;
import com.acquirex.domain.AcquisitionApproval.ApprovalStatus;
import com.acquirex.dto.ApprovalRequest;
import com.acquirex.dto.ApprovalResponse;
import com.acquirex.exception.ApprovalProcessingException;
import com.acquirex.repository.AcquisitionApprovalRepository;
import org.junit.jupiter.api.BeforeEach;
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.Arrays;
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 AcquisitionApprovalServiceTest {
@Mock
private AcquisitionApprovalRepository repository;
@InjectMocks
private AcquisitionApprovalService service;
private ApprovalRequest validRequest;
private AcquisitionApproval savedApproval;
@BeforeEach
void setUp() {
validRequest = new ApprovalRequest("MERCHANT001", "4111111111111111", new BigDecimal("100.00"), "KRW");
savedApproval = new AcquisitionApproval();
savedApproval.setId(1L);
savedApproval.setMerchantId("MERCHANT001");
savedApproval.setCardNumber("****-****-****-1111");
savedApproval.setAmount(new BigDecimal("100.00"));
savedApproval.setCurrency("KRW");
savedApproval.setStatus(ApprovalStatus.PENDING);
}
@Test
void requestApproval_ValidRequest_ReturnsApprovalResponse() {
when(repository.save(any(AcquisitionApproval.class))).thenReturn(savedApproval);
ApprovalResponse response = service.requestApproval(validRequest);
assertNotNull(response);
assertEquals(1L, response.getId());
assertEquals("MERCHANT001", response.getMerchantId());
assertEquals("PENDING", response.getStatus());
assertTrue(response.getCardNumber().startsWith("****"));
verify(repository, times(1)).save(any(AcquisitionApproval.class));
}
@Test
void requestApproval_InvalidMerchantId_ThrowsException() {
validRequest.setMerchantId("");
assertThrows(ApprovalProcessingException.class, () -> service.requestApproval(validRequest));
}
@Test
void requestApproval_InvalidAmount_ThrowsException() {
validRequest.setAmount(BigDecimal.ZERO);
assertThrows(ApprovalProcessingException.class, () -> service.requestApproval(validRequest));
}
@Test
void processApproval_Approve_UpdatesStatusToApproved() {
savedApproval.setStatus(ApprovalStatus.PENDING);
when(repository.findById(1L)).thenReturn(Optional.of(savedApproval));
when(repository.save(any(AcquisitionApproval.class))).thenAnswer(inv -> inv.getArgument(0));
ApprovalResponse response = service.processApproval(1L, true, null);
assertEquals("APPROVED", response.getStatus());
assertNotNull(response.getApprovalCode());
assertTrue(response.getApprovalCode().startsWith("APP-"));
}
@Test
void processApproval_Reject_UpdatesStatusToRejected() {
savedApproval.setStatus(ApprovalStatus.PENDING);
when(repository.findById(1L)).thenReturn(Optional.of(savedApproval));
when(repository.save(any(AcquisitionApproval.class))).thenAnswer(inv -> inv.getArgument(0));
ApprovalResponse response = service.processApproval(1L, false, "Insufficient funds");
assertEquals("REJECTED", response.getStatus());
assertEquals("Insufficient funds", response.getRejectionReason());
assertNull(response.getApprovalCode());
}
@Test
void processApproval_AlreadyProcessed_ThrowsException() {
savedApproval.setStatus(ApprovalStatus.APPROVED);
when(repository.findById(1L)).thenReturn(Optional.of(savedApproval));
assertThrows(ApprovalProcessingException.class, () -> service.processApproval(1L, true, null));
}
@Test
void processApproval_NotFound_ThrowsException() {
when(repository.findById(999L)).thenReturn(Optional.empty());
assertThrows(ApprovalProcessingException.class, () -> service.processApproval(999L, true, null));
}
@Test
void getApproval_ExistingId_ReturnsApproval() {
when(repository.findById(1L)).thenReturn(Optional.of(savedApproval));
ApprovalResponse response = service.getApproval(1L);
assertNotNull(response);
assertEquals(1L, response.getId());
}
@Test
void getApproval_NonExistingId_ThrowsException() {
when(repository.findById(999L)).thenReturn(Optional.empty());
assertThrows(ApprovalProcessingException.class, () -> service.getApproval(999L));
}
@Test
void getPendingApprovals_ReturnsPendingList() {
AcquisitionApproval pending1 = new AcquisitionApproval();
pending1.setId(1L);
pending1.setStatus(ApprovalStatus.PENDING);
pending1.setMerchantId("MERCHANT001");
AcquisitionApproval pending2 = new AcquisitionApproval();
pending2.setId(2L);
pending2.setStatus(ApprovalStatus.PENDING);
pending2.setMerchantId("MERCHANT002");
when(repository.findByStatus(ApprovalStatus.PENDING)).thenReturn(Arrays.asList(pending1, pending2));
List<ApprovalResponse> responses = service.getPendingApprovals();
assertEquals(2, responses.size());
}
@Test
void getApprovalsByMerchant_ReturnsMerchantApprovals() {
when(repository.findByMerchantId("MERCHANT001")).thenReturn(Arrays.asList(savedApproval));
List<ApprovalResponse> responses = service.getApprovalsByMerchant("MERCHANT001");
assertEquals(1, responses.size());
assertEquals("MERCHANT001", responses.get(0).getMerchantId());
}
}