From 8d6e91e89d50bbef174ae24145e5195c5159586e Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 07:29:31 +0000 Subject: [PATCH 01/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 pom.xml diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..afcab6c --- /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 + Card Acquisition Approval System - 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 + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file From c9483167fc82622b187d1383befaea72132dd460 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 07:29:32 +0000 Subject: [PATCH 02/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/acquirex/AcquirexApplication.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/acquirex/AcquirexApplication.java 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 From 1696f372a4a7cb11bbf52c59b6170c6f96ff367d Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 07:29:33 +0000 Subject: [PATCH 03/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../acquirex/domain/AcquisitionApproval.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/main/java/com/acquirex/domain/AcquisitionApproval.java diff --git a/src/main/java/com/acquirex/domain/AcquisitionApproval.java b/src/main/java/com/acquirex/domain/AcquisitionApproval.java new file mode 100644 index 0000000..fe48635 --- /dev/null +++ b/src/main/java/com/acquirex/domain/AcquisitionApproval.java @@ -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; } +} \ No newline at end of file From 7040dace4c86a3c59df8fb922f5677368236e710 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 07:29:34 +0000 Subject: [PATCH 04/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AcquisitionApprovalRepository.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/java/com/acquirex/repository/AcquisitionApprovalRepository.java diff --git a/src/main/java/com/acquirex/repository/AcquisitionApprovalRepository.java b/src/main/java/com/acquirex/repository/AcquisitionApprovalRepository.java new file mode 100644 index 0000000..603a16d --- /dev/null +++ b/src/main/java/com/acquirex/repository/AcquisitionApprovalRepository.java @@ -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 { + + List findByMerchantId(String merchantId); + + List findByStatus(ApprovalStatus status); + + Optional findByApprovalCode(String approvalCode); + + List findByMerchantIdAndStatus(String merchantId, ApprovalStatus status); + + List findByRequestedAtBetween(LocalDateTime start, LocalDateTime end); +} \ No newline at end of file From 692278427d756c72058648ba7dd73e4d76ac77d4 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 07:29:35 +0000 Subject: [PATCH 05/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AcquisitionApprovalService.java | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/main/java/com/acquirex/service/AcquisitionApprovalService.java diff --git a/src/main/java/com/acquirex/service/AcquisitionApprovalService.java b/src/main/java/com/acquirex/service/AcquisitionApprovalService.java new file mode 100644 index 0000000..6d47d2e --- /dev/null +++ b/src/main/java/com/acquirex/service/AcquisitionApprovalService.java @@ -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 getPendingApprovals() { + return repository.findByStatus(ApprovalStatus.PENDING) + .stream() + .map(this::toResponse) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List 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() + ); + } +} \ No newline at end of file From 9e8112e4d7222bd327aaa7cb4147c66e3b801baf Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 07:29:36 +0000 Subject: [PATCH 06/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AcquisitionApprovalController.java | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/main/java/com/acquirex/controller/AcquisitionApprovalController.java diff --git a/src/main/java/com/acquirex/controller/AcquisitionApprovalController.java b/src/main/java/com/acquirex/controller/AcquisitionApprovalController.java new file mode 100644 index 0000000..866a558 --- /dev/null +++ b/src/main/java/com/acquirex/controller/AcquisitionApprovalController.java @@ -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 requestApproval(@Valid @RequestBody ApprovalRequest request) { + ApprovalResponse response = service.requestApproval(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/{id}/approve") + public ResponseEntity approve(@PathVariable Long id) { + ApprovalResponse response = service.processApproval(id, true, null); + return ResponseEntity.ok(response); + } + + @PostMapping("/{id}/reject") + public ResponseEntity reject(@PathVariable Long id, @RequestParam String reason) { + ApprovalResponse response = service.processApproval(id, false, reason); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}") + public ResponseEntity getApproval(@PathVariable Long id) { + ApprovalResponse response = service.getApproval(id); + return ResponseEntity.ok(response); + } + + @GetMapping("/pending") + public ResponseEntity> getPendingApprovals() { + List responses = service.getPendingApprovals(); + return ResponseEntity.ok(responses); + } + + @GetMapping("/merchant/{merchantId}") + public ResponseEntity> getApprovalsByMerchant(@PathVariable String merchantId) { + List 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> handleApprovalProcessingException(ApprovalProcessingException ex) { + Map 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> handleValidationExceptions(MethodArgumentNotValidException ex) { + Map body = new HashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.BAD_REQUEST.value()); + body.put("error", "Validation Error"); + + Map 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); + } +} \ No newline at end of file From c829679c3d3a79633d7c4bbc29d101b63a7bfc62 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 07:29:38 +0000 Subject: [PATCH 07/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.properties | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/resources/application.properties diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..8085883 --- /dev/null +++ b/src/main/resources/application.properties @@ -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 \ No newline at end of file From 6cd9fbdf8ce72d8d6448ba1363a93c16731ebe95 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 07:29:39 +0000 Subject: [PATCH 08/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AcquisitionApprovalServiceTest.java | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 src/test/java/com/acquirex/service/AcquisitionApprovalServiceTest.java diff --git a/src/test/java/com/acquirex/service/AcquisitionApprovalServiceTest.java b/src/test/java/com/acquirex/service/AcquisitionApprovalServiceTest.java new file mode 100644 index 0000000..652dc93 --- /dev/null +++ b/src/test/java/com/acquirex/service/AcquisitionApprovalServiceTest.java @@ -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 responses = service.getPendingApprovals(); + assertEquals(2, responses.size()); + } + + @Test + void getApprovalsByMerchant_ReturnsMerchantApprovals() { + when(repository.findByMerchantId("MERCHANT001")).thenReturn(Arrays.asList(savedApproval)); + + List responses = service.getApprovalsByMerchant("MERCHANT001"); + assertEquals(1, responses.size()); + assertEquals("MERCHANT001", responses.get(0).getMerchantId()); + } +} \ No newline at end of file From 94a894dc935d6339a7a8dd2af51428bb11052833 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 08:30:24 +0000 Subject: [PATCH 09/18] ci: add Forgejo verification workflow --- .forgejo/workflows/verify.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .forgejo/workflows/verify.yml diff --git a/.forgejo/workflows/verify.yml b/.forgejo/workflows/verify.yml new file mode 100644 index 0000000..5a5a050 --- /dev/null +++ b/.forgejo/workflows/verify.yml @@ -0,0 +1,31 @@ +name: Verify + +on: + pull_request: + push: + +permissions: + contents: read + +jobs: + source-contract: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Verify repository entrypoints + run: | + test -f app/build.sh + test -f app/src/ac/ac_svr.pgc + test -d app/src/ac/svc + test -f docker/docker-compose.yml + + - name: Validate JSON artifacts + run: git ls-files -z '*.json' | xargs -0 -r -n1 python3 -m json.tool >/dev/null + + - name: Validate shell scripts + run: git ls-files -z '*.sh' | xargs -0 -r -n1 bash -n + + - name: Compile analysis tools + run: python3 -m compileall -q tools From 7d1e9e5a5b2f1e81f7f42621a92d628debcdb1a6 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 08:48:25 +0000 Subject: [PATCH 10/18] ci: allow manual PR verification --- .forgejo/workflows/verify.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.forgejo/workflows/verify.yml b/.forgejo/workflows/verify.yml index 5a5a050..4895ce7 100644 --- a/.forgejo/workflows/verify.yml +++ b/.forgejo/workflows/verify.yml @@ -3,6 +3,7 @@ name: Verify on: pull_request: push: + workflow_dispatch: permissions: contents: read From 43dca6853c59896e6f07ae005c768ad4f7e4bd04 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 14:07:03 +0000 Subject: [PATCH 11/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 156 +++++++----------------------------------------------- 1 file changed, 20 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index ca4e598..7129e1b 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,28 @@ -# acquire-core-x +# 카드 매입 승인 경로 Spring Boot 3 전환 -**실제로 동작하는** 카드 매입·정산(card acquiring & settlement) 레거시 시스템 — -상용 카드사 스택(Tuxedo / Pro\*C / Oracle / XA)을 **오픈소스 등가물**(Enduro/X · -ECPG/PostgreSQL · XA 2PC) 위에 재현해 **진짜로 빌드·부팅·거래가 커밋**된다. +## 전환 범위 -> **목적** — Klaro **Forge**(자율 마이그레이션 도구)의 **대형 전환 대상 픽스처**. -> "Forge가 이 정도로 복잡하게 얽힌 레거시(바이너리 전문·소켓·임베디드SQL·XA)까지 -> Java Spring Boot 로 옮길 수 있다"를 증명하는 **Before 시스템**이다. 컴파일만 되는 -> 스텁이 아니라 **상용급 TP 미들웨어에서 실제로 도는** 시스템이라는 점이 핵심이다. +| 항목 | 기존 | 전환 후 | +|------|------|----------| +| 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 | ---- - -## 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 -# 전체 스택 빌드+부팅 (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' +./mvnw clean verify ``` -부팅 시 초기화(대량 시드 포함)에 수 분 소요된다. +## API 엔드포인트 ---- - -## 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/ -│ │ ├── / # 11 도메인 모듈 (ac au st py lg cl rc vl mm mg cm) -│ │ │ ├── _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` 가 -소스에서 자동 집계한다("이 숫자 진짜냐"에 코드로 답한다). +| 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 | 전체 매입 요청 목록 | From 3a213c4e4f2b09eab8a46bf4ca731969b57bcb12 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 14:07:04 +0000 Subject: [PATCH 12/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 pom.xml diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..c6cf793 --- /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 + + + + From 469d153f809623b77c13587bc745915e1aa2ced9 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 14:07:05 +0000 Subject: [PATCH 13/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/acquirex/AcquirexApplication.java | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/main/java/com/acquirex/AcquirexApplication.java diff --git a/src/main/java/com/acquirex/AcquirexApplication.java b/src/main/java/com/acquirex/AcquirexApplication.java new file mode 100644 index 0000000..cbf43e9 --- /dev/null +++ b/src/main/java/com/acquirex/AcquirexApplication.java @@ -0,0 +1,11 @@ +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); + } +} From 851f7c1658c1460eff1b1baa2b5d894bb77df4b9 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 14:07:07 +0000 Subject: [PATCH 14/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../acquirex/approval/CardAcquisition.java | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/main/java/com/acquirex/approval/CardAcquisition.java diff --git a/src/main/java/com/acquirex/approval/CardAcquisition.java b/src/main/java/com/acquirex/approval/CardAcquisition.java new file mode 100644 index 0000000..798e8e7 --- /dev/null +++ b/src/main/java/com/acquirex/approval/CardAcquisition.java @@ -0,0 +1,94 @@ +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(); + } +} From 1b88ac69fcfde1072beb5ead45dbd6bbcb01e48b Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 14:07:08 +0000 Subject: [PATCH 15/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../approval/CardAcquisitionService.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/main/java/com/acquirex/approval/CardAcquisitionService.java diff --git a/src/main/java/com/acquirex/approval/CardAcquisitionService.java b/src/main/java/com/acquirex/approval/CardAcquisitionService.java new file mode 100644 index 0000000..fac8b83 --- /dev/null +++ b/src/main/java/com/acquirex/approval/CardAcquisitionService.java @@ -0,0 +1,86 @@ +package com.acquirex.approval; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +/** + * 카드 매입 서비스 + 리포지토리. + * Spring Boot 3 전환: jakarta.transaction.Transactional + */ +@Repository +interface CardAcquisitionRepository extends JpaRepository { + List findByStatus(CardAcquisition.ApprovalStatus status); + List findByMerchantId(String merchantId); +} + +@Service +@Transactional +public class CardAcquisitionService { + + private final CardAcquisitionRepository repository; + + public CardAcquisitionService(CardAcquisitionRepository repository) { + this.repository = repository; + } + + public CardAcquisition createAcquisition(String cardNumber, String merchantId, BigDecimal amount) { + validateAmount(amount); + CardAcquisition acquisition = new CardAcquisition(cardNumber, merchantId, amount); + return repository.save(acquisition); + } + + @Transactional(readOnly = true) + public Optional findById(Long id) { + return repository.findById(id); + } + + @Transactional(readOnly = true) + public List findAll() { + return repository.findAll(); + } + + @Transactional(readOnly = true) + public List findByStatus(CardAcquisition.ApprovalStatus status) { + return repository.findByStatus(status); + } + + public CardAcquisition approve(Long id, String approver) { + CardAcquisition acquisition = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("매입 요청을 찾을 수 없습니다: " + id)); + if (acquisition.getStatus() != CardAcquisition.ApprovalStatus.PENDING) { + throw new IllegalStateException("대기 상태의 요청만 승인할 수 있습니다."); + } + acquisition.approve(approver); + return repository.save(acquisition); + } + + public CardAcquisition reject(Long id, String reason) { + CardAcquisition acquisition = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("매입 요청을 찾을 수 없습니다: " + id)); + if (acquisition.getStatus() != CardAcquisition.ApprovalStatus.PENDING) { + throw new IllegalStateException("대기 상태의 요청만 거절할 수 있습니다."); + } + acquisition.reject(reason); + return repository.save(acquisition); + } + + public CardAcquisition cancel(Long id) { + CardAcquisition acquisition = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("매입 요청을 찾을 수 없습니다: " + id)); + if (acquisition.getStatus() == CardAcquisition.ApprovalStatus.APPROVED) { + throw new IllegalStateException("이미 승인된 요청은 취소할 수 없습니다."); + } + acquisition.cancel(); + return repository.save(acquisition); + } + + private void validateAmount(BigDecimal amount) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("매입 금액은 0보다 커야 합니다."); + } + } +} From 8b0e39d2607fd50cb2ba11bf8d6d134acc56c6bc Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 14:07:09 +0000 Subject: [PATCH 16/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../approval/CardAcquisitionController.java | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/main/java/com/acquirex/approval/CardAcquisitionController.java diff --git a/src/main/java/com/acquirex/approval/CardAcquisitionController.java b/src/main/java/com/acquirex/approval/CardAcquisitionController.java new file mode 100644 index 0000000..8d2e92b --- /dev/null +++ b/src/main/java/com/acquirex/approval/CardAcquisitionController.java @@ -0,0 +1,95 @@ +package com.acquirex.approval; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 카드 매입 승인 REST 컨트롤러. + * Spring Boot 3 전환: javax.validation → jakarta.validation + */ +@RestController +@RequestMapping("/api/v1/acquisitions") +public class CardAcquisitionController { + + private final CardAcquisitionService service; + + public CardAcquisitionController(CardAcquisitionService service) { + this.service = service; + } + + @PostMapping + public ResponseEntity createAcquisition( + @Valid @RequestBody CreateAcquisitionRequest request) { + CardAcquisition acquisition = service.createAcquisition( + request.cardNumber(), request.merchantId(), request.amount()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(CardAcquisitionResponse.from(acquisition)); + } + + @GetMapping("/{id}") + public ResponseEntity getAcquisition(@PathVariable Long id) { + return service.findById(id) + .map(CardAcquisitionResponse::from) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping + public ResponseEntity> getAllAcquisitions( + @RequestParam(required = false) CardAcquisition.ApprovalStatus status) { + List acquisitions = (status != null) + ? service.findByStatus(status) : service.findAll(); + return ResponseEntity.ok(acquisitions.stream().map(CardAcquisitionResponse::from).toList()); + } + + @PostMapping("/{id}/approve") + public ResponseEntity approveAcquisition( + @PathVariable Long id, @RequestBody ApproveRequest request) { + return ResponseEntity.ok(CardAcquisitionResponse.from(service.approve(id, request.approver()))); + } + + @PostMapping("/{id}/reject") + public ResponseEntity rejectAcquisition( + @PathVariable Long id, @RequestBody RejectRequest request) { + return ResponseEntity.ok(CardAcquisitionResponse.from(service.reject(id, request.reason()))); + } + + public record CreateAcquisitionRequest( + @NotBlank String cardNumber, + @NotBlank String merchantId, + @NotNull @Positive BigDecimal amount) {} + + public record ApproveRequest(@NotBlank String approver) {} + + public record RejectRequest(@NotBlank String reason) {} + + public record CardAcquisitionResponse( + Long id, String cardNumber, String merchantId, BigDecimal amount, + CardAcquisition.ApprovalStatus status, String createdAt, String updatedAt, + String approvedBy, String rejectionReason) { + public static CardAcquisitionResponse from(CardAcquisition entity) { + return new CardAcquisitionResponse( + entity.getId(), + maskCardNumber(entity.getCardNumber()), + entity.getMerchantId(), + entity.getAmount(), + entity.getStatus(), + entity.getCreatedAt().toString(), + entity.getUpdatedAt() != null ? entity.getUpdatedAt().toString() : null, + entity.getApprovedBy(), + entity.getRejectionReason()); + } + private static String maskCardNumber(String cardNumber) { + if (cardNumber == null || cardNumber.length() < 8) return "****"; + return cardNumber.substring(0, 4) + "****" + cardNumber.substring(cardNumber.length() - 4); + } + } +} From 0cf60a988070a41501d6ee179431589ce2f1129f Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 14:07:11 +0000 Subject: [PATCH 17/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/resources/application.yml diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..4a5e9a4 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,20 @@ +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 From d571b47db54d4d8fee2043c5f157f46122eb6459 Mon Sep 17 00:00:00 2001 From: forge-bot Date: Thu, 30 Jul 2026 14:07:12 +0000 Subject: [PATCH 18/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=EB=A1=9C=20Spring=20Boot=203?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20(ACX-E2E-1785394564819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../approval/CardAcquisitionServiceTest.java | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/test/java/com/acquirex/approval/CardAcquisitionServiceTest.java diff --git a/src/test/java/com/acquirex/approval/CardAcquisitionServiceTest.java b/src/test/java/com/acquirex/approval/CardAcquisitionServiceTest.java new file mode 100644 index 0000000..b0098a5 --- /dev/null +++ b/src/test/java/com/acquirex/approval/CardAcquisitionServiceTest.java @@ -0,0 +1,102 @@ +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 result = service.findByStatus(CardAcquisition.ApprovalStatus.PENDING); + assertThat(result).hasSize(1); + } +}