feat: complete Spring Boot 카드 매입·정산 system (acquire-core migration target, mvn verify green, 24 tests)
This commit is contained in:
commit
0f6e3acfe6
92 changed files with 4320 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
target/
|
||||||
|
*.class
|
||||||
|
.idea/
|
||||||
91
app/pom.xml
Normal file
91
app/pom.xml
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>card-acquiring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>app</artifactId>
|
||||||
|
<name>app</name>
|
||||||
|
<description>Spring Boot 부트 모듈: 전 모듈 조립 + REST + 배치 런처</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>gateway</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>acquiring</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>reconcile</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>settlement</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>payment</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>ledger</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>master</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>closing</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-batch</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.batch</groupId>
|
||||||
|
<artifactId>spring-batch-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<skip>false</skip>
|
||||||
|
<mainClass>com.klaro.acquiring.app.CardAcquiringApplication</mainClass>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.klaro.acquiring.app;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 매입·정산 시스템 부트 애플리케이션. 전 모듈(mg/ac/rc/vl/st/py/lg/mm/cm/cl)을 조립한다.
|
||||||
|
*/
|
||||||
|
@SpringBootApplication(scanBasePackages = "com.klaro.acquiring")
|
||||||
|
@EntityScan(basePackages = "com.klaro.acquiring.domain.entity")
|
||||||
|
@EnableJpaRepositories(basePackages = "com.klaro.acquiring.persistence.repository")
|
||||||
|
public class CardAcquiringApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(CardAcquiringApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package com.klaro.acquiring.app.web;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.acquiring.AcquiringService;
|
||||||
|
import com.klaro.acquiring.acquiring.dto.PurchaseCommand;
|
||||||
|
import com.klaro.acquiring.common.dto.ApiResponse;
|
||||||
|
import com.klaro.acquiring.domain.entity.Purchase;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/** 매입(ac) 온라인 REST. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/acquiring/purchases")
|
||||||
|
public class AcquiringController {
|
||||||
|
|
||||||
|
private final AcquiringService acquiringService;
|
||||||
|
|
||||||
|
public AcquiringController(AcquiringService acquiringService) {
|
||||||
|
this.acquiringService = acquiringService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<PurchaseView> accept(@Valid @RequestBody PurchaseAcceptRequest req) {
|
||||||
|
Purchase p = acquiringService.acceptPurchase(new PurchaseCommand(req.apprNo(), req.amount()));
|
||||||
|
return ApiResponse.ok(PurchaseView.from(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{apprNo}")
|
||||||
|
public ApiResponse<PurchaseView> get(@PathVariable String apprNo) {
|
||||||
|
return ApiResponse.ok(PurchaseView.from(acquiringService.getByApprNo(apprNo)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PurchaseView(Long purchaseId, String apprNo, String merchId, BigDecimal amount,
|
||||||
|
BigDecimal mdrFee, BigDecimal vanFee, BigDecimal netAmount,
|
||||||
|
String status) {
|
||||||
|
static PurchaseView from(Purchase p) {
|
||||||
|
return new PurchaseView(p.getPurchaseId(), p.getApprNo(), p.getMerchId(), p.getAmount(),
|
||||||
|
p.getMdrFee(), p.getVanFee(), p.getNetAmount(), p.getStatus().name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
package com.klaro.acquiring.app.web;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.dto.ApiResponse;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.JobExecution;
|
||||||
|
import org.springframework.batch.core.JobParameters;
|
||||||
|
import org.springframework.batch.core.JobParametersBuilder;
|
||||||
|
import org.springframework.batch.core.launch.JobLauncher;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** 배치 잡 런처 REST (대사/정산/마감). */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/batch")
|
||||||
|
public class BatchController {
|
||||||
|
|
||||||
|
private final JobLauncher jobLauncher;
|
||||||
|
private final Job reconcileJob;
|
||||||
|
private final Job settlementJob;
|
||||||
|
private final Job closingJob;
|
||||||
|
|
||||||
|
public BatchController(JobLauncher jobLauncher,
|
||||||
|
@Qualifier("reconcileJob") Job reconcileJob,
|
||||||
|
@Qualifier("settlementJob") Job settlementJob,
|
||||||
|
@Qualifier("closingJob") Job closingJob) {
|
||||||
|
this.jobLauncher = jobLauncher;
|
||||||
|
this.reconcileJob = reconcileJob;
|
||||||
|
this.settlementJob = settlementJob;
|
||||||
|
this.closingJob = closingJob;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/reconcile")
|
||||||
|
public ApiResponse<String> reconcile(@RequestParam String bizDate) throws Exception {
|
||||||
|
return run(reconcileJob, bizDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/settlement")
|
||||||
|
public ApiResponse<String> settlement(@RequestParam String bizDate) throws Exception {
|
||||||
|
return run(settlementJob, bizDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/closing")
|
||||||
|
public ApiResponse<String> closing(@RequestParam String bizDate) throws Exception {
|
||||||
|
return run(closingJob, bizDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApiResponse<String> run(Job job, String bizDate) throws Exception {
|
||||||
|
JobParameters params = new JobParametersBuilder()
|
||||||
|
.addString("bizDate", bizDate)
|
||||||
|
.addLong("ts", System.currentTimeMillis())
|
||||||
|
.toJobParameters();
|
||||||
|
JobExecution exec = jobLauncher.run(job, params);
|
||||||
|
return ApiResponse.ok(exec.getStatus().name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.klaro.acquiring.app.web;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.gateway.GatewayService;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** 전문 게이트웨이(mg) REST. 고정길이 전문 in/out(text/plain). */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/gateway")
|
||||||
|
public class GatewayController {
|
||||||
|
|
||||||
|
private final GatewayService gatewayService;
|
||||||
|
|
||||||
|
public GatewayController(GatewayService gatewayService) {
|
||||||
|
this.gatewayService = gatewayService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/message", consumes = MediaType.TEXT_PLAIN_VALUE,
|
||||||
|
produces = MediaType.TEXT_PLAIN_VALUE)
|
||||||
|
public String message(@RequestBody String message) {
|
||||||
|
return gatewayService.handle(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
package com.klaro.acquiring.app.web;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.dto.ApiResponse;
|
||||||
|
import com.klaro.acquiring.common.error.ErrorCode;
|
||||||
|
import com.klaro.acquiring.common.error.AcquiringException;
|
||||||
|
import com.klaro.acquiring.common.error.NotFoundException;
|
||||||
|
import com.klaro.acquiring.common.error.ValidationException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
/** 도메인 예외 → 표준 응답 매핑. */
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(NotFoundException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleNotFound(NotFoundException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(ApiResponse.error(e.getErrorCode(), e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ValidationException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleValidation(ValidationException e) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(ApiResponse.error(e.getErrorCode(), e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(AcquiringException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleDomain(AcquiringException e) {
|
||||||
|
return ResponseEntity.unprocessableEntity()
|
||||||
|
.body(ApiResponse.error(e.getErrorCode(), e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleBeanValidation(MethodArgumentNotValidException e) {
|
||||||
|
String msg = e.getBindingResult().getFieldErrors().stream()
|
||||||
|
.findFirst()
|
||||||
|
.map(f -> f.getField() + ": " + f.getDefaultMessage())
|
||||||
|
.orElse("요청값 오류");
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(ApiResponse.error(ErrorCode.INVALID_REQUEST, msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.klaro.acquiring.app.web;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/** 매입 접수 REST 요청. */
|
||||||
|
public record PurchaseAcceptRequest(
|
||||||
|
@NotBlank String apprNo,
|
||||||
|
@NotNull @DecimalMin(value = "0.01") BigDecimal amount) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package com.klaro.acquiring.app.web;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.dto.ApiResponse;
|
||||||
|
import com.klaro.acquiring.common.util.DateUtil;
|
||||||
|
import com.klaro.acquiring.domain.entity.Settlement;
|
||||||
|
import com.klaro.acquiring.settlement.SettlementService;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 정산(st) 조회 REST. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/settlement")
|
||||||
|
public class SettlementController {
|
||||||
|
|
||||||
|
private final SettlementService settlementService;
|
||||||
|
|
||||||
|
public SettlementController(SettlementService settlementService) {
|
||||||
|
this.settlementService = settlementService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<List<SettlementView>> byBizDate(@RequestParam String bizDate) {
|
||||||
|
LocalDate date = DateUtil.parse(bizDate);
|
||||||
|
return ApiResponse.ok(settlementService.listByBizDate(date).stream()
|
||||||
|
.map(SettlementView::from).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/merchant")
|
||||||
|
public ApiResponse<SettlementView> byMerchant(@RequestParam String merchId,
|
||||||
|
@RequestParam String bizDate) {
|
||||||
|
LocalDate date = DateUtil.parse(bizDate);
|
||||||
|
return ApiResponse.ok(SettlementView.from(settlementService.get(merchId, date)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SettlementView(Long settlementId, String merchId, String bizDate,
|
||||||
|
BigDecimal grossAmount, BigDecimal totalFee, BigDecimal netAmount,
|
||||||
|
int txnCount, String payDate, String status) {
|
||||||
|
static SettlementView from(Settlement s) {
|
||||||
|
return new SettlementView(s.getSettlementId(), s.getMerchId(),
|
||||||
|
DateUtil.format(s.getBizDate()), s.getGrossAmount(), s.getTotalFee(),
|
||||||
|
s.getNetAmount(), s.getTxnCount(), DateUtil.format(s.getPayDate()),
|
||||||
|
s.getStatus().name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/src/main/resources/application.yml
Normal file
32
app/src/main/resources/application.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: card-acquiring-boot
|
||||||
|
datasource:
|
||||||
|
url: jdbc:h2:mem:acquiring;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
username: sa
|
||||||
|
password: ""
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: none
|
||||||
|
open-in-view: false
|
||||||
|
properties:
|
||||||
|
hibernate.jdbc.time_zone: Asia/Seoul
|
||||||
|
flyway:
|
||||||
|
enabled: true
|
||||||
|
locations: classpath:db/migration
|
||||||
|
batch:
|
||||||
|
job:
|
||||||
|
enabled: false
|
||||||
|
jdbc:
|
||||||
|
initialize-schema: always
|
||||||
|
h2:
|
||||||
|
console:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.klaro.acquiring: INFO
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package com.klaro.acquiring.app;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.acquiring.AcquiringService;
|
||||||
|
import com.klaro.acquiring.acquiring.dto.PurchaseCommand;
|
||||||
|
import com.klaro.acquiring.common.error.AcquiringException;
|
||||||
|
import com.klaro.acquiring.domain.entity.Approval;
|
||||||
|
import com.klaro.acquiring.domain.entity.Purchase;
|
||||||
|
import com.klaro.acquiring.domain.enums.TxStatus;
|
||||||
|
import com.klaro.acquiring.persistence.repository.ApprovalRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.FeeRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/** 매입 서비스 + 리포지토리 슬라이스(@SpringBootTest, H2). */
|
||||||
|
@SpringBootTest
|
||||||
|
@Transactional
|
||||||
|
class AcquiringServiceTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AcquiringService acquiringService;
|
||||||
|
@Autowired
|
||||||
|
private ApprovalRepository approvalRepository;
|
||||||
|
@Autowired
|
||||||
|
private FeeRepository feeRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsPurchaseAndComputesFee() {
|
||||||
|
LocalDate bizDate = LocalDate.of(2026, 7, 17);
|
||||||
|
approvalRepository.save(new Approval("IT-A-0001", "M0000000000001", "123456******7890",
|
||||||
|
new BigDecimal("10000.00"), "20260717", bizDate));
|
||||||
|
|
||||||
|
Purchase p = acquiringService.acceptPurchase(new PurchaseCommand("IT-A-0001",
|
||||||
|
new BigDecimal("10000.00")));
|
||||||
|
|
||||||
|
// MDR 2.3% = 230, VAN 30 => fee 260, net 9740
|
||||||
|
assertThat(p.getMdrFee()).isEqualByComparingTo("230");
|
||||||
|
assertThat(p.getVanFee()).isEqualByComparingTo("30");
|
||||||
|
assertThat(p.getNetAmount()).isEqualByComparingTo("9740");
|
||||||
|
assertThat(p.getStatus()).isEqualTo(TxStatus.DONE);
|
||||||
|
assertThat(feeRepository.findByPurchaseId(p.getPurchaseId())).hasSize(2);
|
||||||
|
assertThat(approvalRepository.findByApprNo("IT-A-0001").orElseThrow().getStatus())
|
||||||
|
.isEqualTo(TxStatus.DONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDuplicatePurchase() {
|
||||||
|
LocalDate bizDate = LocalDate.of(2026, 7, 17);
|
||||||
|
approvalRepository.save(new Approval("IT-A-0002", "M0000000000001", "123456******7890",
|
||||||
|
new BigDecimal("5000.00"), "20260717", bizDate));
|
||||||
|
acquiringService.acceptPurchase(new PurchaseCommand("IT-A-0002", new BigDecimal("5000.00")));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> acquiringService.acceptPurchase(
|
||||||
|
new PurchaseCommand("IT-A-0002", new BigDecimal("5000.00"))))
|
||||||
|
.isInstanceOf(AcquiringException.class)
|
||||||
|
.hasMessageContaining("이미 매입");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsCaptureOverApproval() {
|
||||||
|
LocalDate bizDate = LocalDate.of(2026, 7, 17);
|
||||||
|
approvalRepository.save(new Approval("IT-A-0003", "M0000000000001", "123456******7890",
|
||||||
|
new BigDecimal("5000.00"), "20260717", bizDate));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> acquiringService.acceptPurchase(
|
||||||
|
new PurchaseCommand("IT-A-0003", new BigDecimal("9000.00"))))
|
||||||
|
.isInstanceOf(AcquiringException.class)
|
||||||
|
.hasMessageContaining("초과");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.klaro.acquiring.app;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
/** 전 모듈 조립(ApplicationContext) 로드 검증. */
|
||||||
|
@SpringBootTest
|
||||||
|
class CardAcquiringApplicationTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contextLoads() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package com.klaro.acquiring.app;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.acquiring.AcquiringService;
|
||||||
|
import com.klaro.acquiring.acquiring.dto.PurchaseCommand;
|
||||||
|
import com.klaro.acquiring.domain.entity.Approval;
|
||||||
|
import com.klaro.acquiring.domain.entity.Deposit;
|
||||||
|
import com.klaro.acquiring.domain.entity.ReconcileResult;
|
||||||
|
import com.klaro.acquiring.persistence.repository.ApprovalRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.DepositRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.ReconcileResultRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.batch.core.BatchStatus;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.JobExecution;
|
||||||
|
import org.springframework.batch.core.JobParameters;
|
||||||
|
import org.springframework.batch.core.JobParametersBuilder;
|
||||||
|
import org.springframework.batch.test.JobLauncherTestUtils;
|
||||||
|
import org.springframework.batch.test.context.SpringBatchTest;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/** 대사 배치 Job 테스트: 매칭 1건 + 불일치(입금누락) 1건. */
|
||||||
|
@SpringBootTest
|
||||||
|
@SpringBatchTest
|
||||||
|
class ReconcileJobTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobLauncherTestUtils jobLauncherTestUtils;
|
||||||
|
@Autowired
|
||||||
|
@Qualifier("reconcileJob")
|
||||||
|
private Job reconcileJob;
|
||||||
|
@Autowired
|
||||||
|
private AcquiringService acquiringService;
|
||||||
|
@Autowired
|
||||||
|
private ApprovalRepository approvalRepository;
|
||||||
|
@Autowired
|
||||||
|
private DepositRepository depositRepository;
|
||||||
|
@Autowired
|
||||||
|
private ReconcileResultRepository reconcileRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reconcilesThreeWayMatchAndDetectsMismatch() throws Exception {
|
||||||
|
LocalDate bizDate = LocalDate.of(2026, 7, 21);
|
||||||
|
// 정상 매칭 건: 승인=매입=입금=10000
|
||||||
|
approvalRepository.save(new Approval("IT-R-0001", "M0000000000001", "123456******7890",
|
||||||
|
new BigDecimal("10000.00"), "20260721", bizDate));
|
||||||
|
acquiringService.acceptPurchase(new PurchaseCommand("IT-R-0001", new BigDecimal("10000.00")));
|
||||||
|
depositRepository.save(new Deposit("IT-R-0001", "M0000000000001",
|
||||||
|
new BigDecimal("10000.00"), bizDate));
|
||||||
|
// 불일치 건: 매입은 있으나 입금 누락
|
||||||
|
approvalRepository.save(new Approval("IT-R-0002", "M0000000000001", "123456******7890",
|
||||||
|
new BigDecimal("5000.00"), "20260721", bizDate));
|
||||||
|
acquiringService.acceptPurchase(new PurchaseCommand("IT-R-0002", new BigDecimal("5000.00")));
|
||||||
|
|
||||||
|
jobLauncherTestUtils.setJob(reconcileJob);
|
||||||
|
JobParameters params = new JobParametersBuilder()
|
||||||
|
.addString("bizDate", "20260721")
|
||||||
|
.addLong("ts", System.currentTimeMillis())
|
||||||
|
.toJobParameters();
|
||||||
|
JobExecution exec = jobLauncherTestUtils.launchJob(params);
|
||||||
|
|
||||||
|
assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
|
||||||
|
List<ReconcileResult> results = reconcileRepository.findByBizDate(bizDate);
|
||||||
|
assertThat(results).hasSize(2);
|
||||||
|
ReconcileResult matched = reconcileRepository
|
||||||
|
.findByBizDateAndApprNo(bizDate, "IT-R-0001").orElseThrow();
|
||||||
|
assertThat(matched.isMatched()).isTrue();
|
||||||
|
ReconcileResult mismatch = reconcileRepository
|
||||||
|
.findByBizDateAndApprNo(bizDate, "IT-R-0002").orElseThrow();
|
||||||
|
assertThat(mismatch.isMatched()).isFalse();
|
||||||
|
assertThat(mismatch.getReason()).contains("입금 누락");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package com.klaro.acquiring.app;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.acquiring.AcquiringService;
|
||||||
|
import com.klaro.acquiring.acquiring.dto.PurchaseCommand;
|
||||||
|
import com.klaro.acquiring.domain.entity.Approval;
|
||||||
|
import com.klaro.acquiring.domain.entity.Settlement;
|
||||||
|
import com.klaro.acquiring.persistence.repository.ApprovalRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.SettlementRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.batch.core.BatchStatus;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.JobExecution;
|
||||||
|
import org.springframework.batch.core.JobParameters;
|
||||||
|
import org.springframework.batch.core.JobParametersBuilder;
|
||||||
|
import org.springframework.batch.test.JobLauncherTestUtils;
|
||||||
|
import org.springframework.batch.test.context.SpringBatchTest;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/** 정산 배치 Job 테스트(JobLauncherTestUtils). */
|
||||||
|
@SpringBootTest
|
||||||
|
@SpringBatchTest
|
||||||
|
class SettlementJobTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobLauncherTestUtils jobLauncherTestUtils;
|
||||||
|
@Autowired
|
||||||
|
@Qualifier("settlementJob")
|
||||||
|
private Job settlementJob;
|
||||||
|
@Autowired
|
||||||
|
private AcquiringService acquiringService;
|
||||||
|
@Autowired
|
||||||
|
private ApprovalRepository approvalRepository;
|
||||||
|
@Autowired
|
||||||
|
private SettlementRepository settlementRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runsSettlementJobAndAggregatesNet() throws Exception {
|
||||||
|
LocalDate bizDate = LocalDate.of(2026, 7, 20);
|
||||||
|
// 두 건 매입 -> 정산 대상
|
||||||
|
approvalRepository.save(new Approval("IT-S-0001", "M0000000000001", "123456******7890",
|
||||||
|
new BigDecimal("10000.00"), "20260720", bizDate));
|
||||||
|
approvalRepository.save(new Approval("IT-S-0002", "M0000000000001", "123456******7890",
|
||||||
|
new BigDecimal("20000.00"), "20260720", bizDate));
|
||||||
|
acquiringService.acceptPurchase(new PurchaseCommand("IT-S-0001", new BigDecimal("10000.00")));
|
||||||
|
acquiringService.acceptPurchase(new PurchaseCommand("IT-S-0002", new BigDecimal("20000.00")));
|
||||||
|
|
||||||
|
jobLauncherTestUtils.setJob(settlementJob);
|
||||||
|
JobParameters params = new JobParametersBuilder()
|
||||||
|
.addString("bizDate", "20260720")
|
||||||
|
.addLong("ts", System.currentTimeMillis())
|
||||||
|
.toJobParameters();
|
||||||
|
JobExecution exec = jobLauncherTestUtils.launchJob(params);
|
||||||
|
|
||||||
|
assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
|
||||||
|
Settlement s = settlementRepository
|
||||||
|
.findByMerchIdAndBizDate("M0000000000001", bizDate).orElseThrow();
|
||||||
|
// gross 30000, MDR 2.3% => 690, VAN 30*2=60, fee 750, net 29250
|
||||||
|
assertThat(s.getGrossAmount()).isEqualByComparingTo("30000");
|
||||||
|
assertThat(s.getTotalFee()).isEqualByComparingTo("750");
|
||||||
|
assertThat(s.getNetAmount()).isEqualByComparingTo("29250");
|
||||||
|
assertThat(s.getTxnCount()).isEqualTo(2);
|
||||||
|
// 정산주기 T+2 영업일: 2026-07-20(월) +2 => 2026-07-22(수)
|
||||||
|
assertThat(s.getPayDate()).isEqualTo(LocalDate.of(2026, 7, 22));
|
||||||
|
}
|
||||||
|
}
|
||||||
34
common-framework/pom.xml
Normal file
34
common-framework/pom.xml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>card-acquiring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>common-framework</artifactId>
|
||||||
|
<name>common-framework</name>
|
||||||
|
<description>TxCore 프레임워크의 Spring 대체: TxContext, MessageCodec(전문 codec), TxTemplate, 유틸, 예외체계</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework</groupId>
|
||||||
|
<artifactId>spring-context</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework</groupId>
|
||||||
|
<artifactId>spring-tx</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.klaro.acquiring.common.codec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전문 단일 필드 정의 (FML/UBF 필드 테이블의 한 행에 대응).
|
||||||
|
*
|
||||||
|
* @param name 필드명
|
||||||
|
* @param length 고정 길이(byte)
|
||||||
|
* @param type 필드 유형
|
||||||
|
*/
|
||||||
|
public record FieldSpec(String name, int length, FieldType type) {
|
||||||
|
|
||||||
|
public FieldSpec {
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("필드명은 필수입니다");
|
||||||
|
}
|
||||||
|
if (length <= 0) {
|
||||||
|
throw new IllegalArgumentException("필드 길이는 1 이상이어야 합니다: " + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FieldSpec an(String name, int length) {
|
||||||
|
return new FieldSpec(name, length, FieldType.ALPHANUMERIC);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FieldSpec num(String name, int length) {
|
||||||
|
return new FieldSpec(name, length, FieldType.NUMERIC);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.klaro.acquiring.common.codec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전문 필드 유형. 고정길이 인코딩 규칙을 결정한다.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #ALPHANUMERIC} : 좌측정렬, 우측 공백 패딩 (문자열)</li>
|
||||||
|
* <li>{@link #NUMERIC} : 우측정렬, 좌측 0 패딩 (금액/숫자)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public enum FieldType {
|
||||||
|
ALPHANUMERIC,
|
||||||
|
NUMERIC
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
package com.klaro.acquiring.common.codec;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.error.ErrorCode;
|
||||||
|
import com.klaro.acquiring.common.error.AcquiringException;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고정길이 전문 코덱. 레거시 TxCore 의 TXBUF(FML/UBF) + 고정길이 전문 송수신을
|
||||||
|
* 대체한다. 필드값 Map 과 고정길이 문자열 사이를 왕복(round-trip) 변환한다.
|
||||||
|
*
|
||||||
|
* <p>인코딩 규칙:
|
||||||
|
* <ul>
|
||||||
|
* <li>ALPHANUMERIC : 좌측정렬 후 우측 공백 패딩, 초과 시 잘림</li>
|
||||||
|
* <li>NUMERIC : 우측정렬 후 좌측 0 패딩, 초과 시 하위자리 유지(왼쪽 잘림)</li>
|
||||||
|
* </ul>
|
||||||
|
* 디코딩 시 ALPHANUMERIC 은 우측 공백 제거, NUMERIC 은 선행 0 제거(빈값은 "0").
|
||||||
|
*/
|
||||||
|
public class MessageCodec {
|
||||||
|
|
||||||
|
private final MessageSpec spec;
|
||||||
|
|
||||||
|
public MessageCodec(MessageSpec spec) {
|
||||||
|
this.spec = spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageSpec spec() {
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 필드값 Map → 고정길이 전문 문자열. */
|
||||||
|
public String encode(Map<String, String> values) {
|
||||||
|
StringBuilder sb = new StringBuilder(spec.totalLength());
|
||||||
|
for (FieldSpec f : spec.fields()) {
|
||||||
|
String raw = values.getOrDefault(f.name(), "");
|
||||||
|
if (raw == null) {
|
||||||
|
raw = "";
|
||||||
|
}
|
||||||
|
sb.append(pad(raw, f));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 고정길이 전문 문자열 → 필드값 Map (선언 순서 유지). */
|
||||||
|
public Map<String, String> decode(String message) {
|
||||||
|
if (message == null) {
|
||||||
|
throw new AcquiringException(ErrorCode.INVALID_REQUEST, "전문이 null 입니다");
|
||||||
|
}
|
||||||
|
int expected = spec.totalLength();
|
||||||
|
if (message.length() < expected) {
|
||||||
|
throw new AcquiringException(ErrorCode.INVALID_REQUEST,
|
||||||
|
"전문 길이 부족: 기대=" + expected + " 실제=" + message.length()
|
||||||
|
+ " [" + spec.name() + "]");
|
||||||
|
}
|
||||||
|
Map<String, String> out = new LinkedHashMap<>();
|
||||||
|
int pos = 0;
|
||||||
|
for (FieldSpec f : spec.fields()) {
|
||||||
|
String chunk = message.substring(pos, pos + f.length());
|
||||||
|
pos += f.length();
|
||||||
|
out.put(f.name(), unpad(chunk, f.type()));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String pad(String raw, FieldSpec f) {
|
||||||
|
int len = f.length();
|
||||||
|
if (f.type() == FieldType.NUMERIC) {
|
||||||
|
String digits = raw.isBlank() ? "0" : raw.trim();
|
||||||
|
if (digits.length() > len) {
|
||||||
|
digits = digits.substring(digits.length() - len);
|
||||||
|
}
|
||||||
|
return "0".repeat(len - digits.length()) + digits;
|
||||||
|
}
|
||||||
|
String v = raw;
|
||||||
|
if (v.length() > len) {
|
||||||
|
return v.substring(0, len);
|
||||||
|
}
|
||||||
|
return v + " ".repeat(len - v.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String unpad(String chunk, FieldType type) {
|
||||||
|
if (type == FieldType.NUMERIC) {
|
||||||
|
String s = chunk.trim();
|
||||||
|
int i = 0;
|
||||||
|
while (i < s.length() - 1 && s.charAt(i) == '0') {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
String stripped = s.isEmpty() ? "0" : s.substring(i);
|
||||||
|
return stripped.isEmpty() ? "0" : stripped;
|
||||||
|
}
|
||||||
|
// 우측 공백 제거만 수행 (선행 공백은 유의미할 수 있어 보존)
|
||||||
|
int end = chunk.length();
|
||||||
|
while (end > 0 && chunk.charAt(end - 1) == ' ') {
|
||||||
|
end--;
|
||||||
|
}
|
||||||
|
return chunk.substring(0, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package com.klaro.acquiring.common.codec;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전문 레이아웃(고정길이 필드의 순서 있는 목록). FML 필드 테이블 전체에 대응.
|
||||||
|
*/
|
||||||
|
public record MessageSpec(String name, List<FieldSpec> fields) {
|
||||||
|
|
||||||
|
public MessageSpec {
|
||||||
|
if (fields == null || fields.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("전문 레이아웃에는 최소 1개 필드가 필요합니다");
|
||||||
|
}
|
||||||
|
fields = List.copyOf(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전문 총 길이(byte). */
|
||||||
|
public int totalLength() {
|
||||||
|
return fields.stream().mapToInt(FieldSpec::length).sum();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageSpec of(String name, FieldSpec... fields) {
|
||||||
|
return new MessageSpec(name, List.of(fields));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.klaro.acquiring.common.dto;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.error.ErrorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표준 응답 봉투. 레거시 응답 TXBUF 의 RESPCODE/데이터 구성을 대체.
|
||||||
|
*/
|
||||||
|
public record ApiResponse<T>(String code, String message, T data) {
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> ok(T data) {
|
||||||
|
return new ApiResponse<>(ErrorCode.OK.code(), ErrorCode.OK.defaultMessage(), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> error(ErrorCode code, String message) {
|
||||||
|
return new ApiResponse<>(code.code(), message, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package com.klaro.acquiring.common.error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인 예외 최상위. 모든 업무 예외는 ErrorCode 를 동반한다.
|
||||||
|
*/
|
||||||
|
public class AcquiringException extends RuntimeException {
|
||||||
|
|
||||||
|
private final ErrorCode errorCode;
|
||||||
|
|
||||||
|
public AcquiringException(ErrorCode errorCode, String message) {
|
||||||
|
super(message);
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AcquiringException(ErrorCode errorCode, String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ErrorCode getErrorCode() {
|
||||||
|
return errorCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.klaro.acquiring.common.error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표준 오류코드. TxCore 의 TX_OK/TX_FAIL/TX_ENOENT/TX_EINVAL/TX_EDB 계열을 대체한다.
|
||||||
|
*/
|
||||||
|
public enum ErrorCode {
|
||||||
|
OK("0000", "정상"),
|
||||||
|
INVALID_REQUEST("E001", "요청값 오류"),
|
||||||
|
NOT_FOUND("E002", "대상 없음"),
|
||||||
|
DUPLICATE("E003", "중복"),
|
||||||
|
LIMIT_EXCEEDED("E004", "한도 초과"),
|
||||||
|
RECONCILE_MISMATCH("E005", "대사 불일치"),
|
||||||
|
BALANCE_MISMATCH("E006", "잔액 불일치"),
|
||||||
|
PERSISTENCE_ERROR("E007", "저장소 오류"),
|
||||||
|
ILLEGAL_STATE("E008", "상태 오류"),
|
||||||
|
INTERNAL_ERROR("E999", "내부 오류");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final String message;
|
||||||
|
|
||||||
|
ErrorCode(String code, String message) {
|
||||||
|
this.code = code;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String code() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String defaultMessage() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.klaro.acquiring.common.error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대상 미존재. TxCore TX_ENOENT 대응.
|
||||||
|
*/
|
||||||
|
public class NotFoundException extends AcquiringException {
|
||||||
|
|
||||||
|
public NotFoundException(String message) {
|
||||||
|
super(ErrorCode.NOT_FOUND, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.klaro.acquiring.common.error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 검증 실패. TxCore TX_EINVAL 대응.
|
||||||
|
*/
|
||||||
|
public class ValidationException extends AcquiringException {
|
||||||
|
|
||||||
|
public ValidationException(String message) {
|
||||||
|
super(ErrorCode.INVALID_REQUEST, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package com.klaro.acquiring.common.tx;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TxCore TXSVCINFO 의 컨텍스트 요소(xid, 서비스명, 영업일)를 대체하는 경량 컨텍스트.
|
||||||
|
* 스레드로컬로 현재 처리 컨텍스트를 전달한다.
|
||||||
|
*/
|
||||||
|
public final class TxContext {
|
||||||
|
|
||||||
|
private static final AtomicLong XID_SEQ = new AtomicLong(0);
|
||||||
|
private static final ThreadLocal<TxContext> CURRENT = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private final long xid;
|
||||||
|
private final String service;
|
||||||
|
private final LocalDate bizDate;
|
||||||
|
|
||||||
|
private TxContext(long xid, String service, LocalDate bizDate) {
|
||||||
|
this.xid = xid;
|
||||||
|
this.service = service;
|
||||||
|
this.bizDate = bizDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TxContext begin(String service, LocalDate bizDate) {
|
||||||
|
TxContext ctx = new TxContext(XID_SEQ.incrementAndGet(), service, bizDate);
|
||||||
|
CURRENT.set(ctx);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TxContext current() {
|
||||||
|
return CURRENT.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
CURRENT.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
public long xid() {
|
||||||
|
return xid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String service() {
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate bizDate() {
|
||||||
|
return bizDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "TxContext{xid=" + xid + ", service='" + service + "', bizDate=" + bizDate + '}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.klaro.acquiring.common.tx;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TxCore 의 tx_begin/tx_commit/tx_abort 관용구를 대체하는 프로그래밍 트랜잭션 헬퍼.
|
||||||
|
* 선언적 {@code @Transactional} 로 표현하기 어려운 배치 스텝 등에서 사용한다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class TxTemplate {
|
||||||
|
|
||||||
|
private final TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
|
public TxTemplate(PlatformTransactionManager transactionManager) {
|
||||||
|
this.transactionTemplate = new TransactionTemplate(transactionManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 결과를 반환하는 트랜잭션 실행 (예외 시 롤백 = tx_abort). */
|
||||||
|
public <T> T execute(Supplier<T> work) {
|
||||||
|
return transactionTemplate.execute(status -> work.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 반환값 없는 트랜잭션 실행. */
|
||||||
|
public void run(Runnable work) {
|
||||||
|
transactionTemplate.executeWithoutResult(status -> work.run());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package com.klaro.acquiring.common.util;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 금액(원) 유틸. 모든 금전 계산은 BigDecimal 로 수행하고 원 단위로 반올림한다
|
||||||
|
* (레거시는 long 원화 정수; 수수료 계산 시 절사/반올림 규칙을 명시화).
|
||||||
|
*/
|
||||||
|
public final class AmountUtil {
|
||||||
|
|
||||||
|
/** 원 단위 (소수점 0자리). */
|
||||||
|
public static final int WON_SCALE = 0;
|
||||||
|
|
||||||
|
private AmountUtil() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BigDecimal won(long amount) {
|
||||||
|
return BigDecimal.valueOf(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 요율 적용 후 원 단위 반올림(HALF_UP). 예: 금액 10000 * 2.3% = 230. */
|
||||||
|
public static BigDecimal applyRate(BigDecimal amount, BigDecimal rate) {
|
||||||
|
if (amount == null || rate == null) {
|
||||||
|
throw new IllegalArgumentException("금액/요율은 null 일 수 없습니다");
|
||||||
|
}
|
||||||
|
return amount.multiply(rate).setScale(WON_SCALE, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원 단위 정규화. */
|
||||||
|
public static BigDecimal normalize(BigDecimal amount) {
|
||||||
|
return amount == null ? BigDecimal.ZERO : amount.setScale(WON_SCALE, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BigDecimal nz(BigDecimal amount) {
|
||||||
|
return amount == null ? BigDecimal.ZERO : amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** |a - b| <= tolerance 이면 true (대사 허용오차 비교). */
|
||||||
|
public static boolean withinTolerance(BigDecimal a, BigDecimal b, BigDecimal tolerance) {
|
||||||
|
return nz(a).subtract(nz(b)).abs().compareTo(nz(tolerance)) <= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
package com.klaro.acquiring.common.util;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.error.ValidationException;
|
||||||
|
|
||||||
|
import java.time.DayOfWeek;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영업일(YYYYMMDD) 유틸. 레거시 acq_util 의 date_is_valid / 영업일 계산 대응.
|
||||||
|
* 주말(토/일)은 비영업일로 간주한다.
|
||||||
|
*/
|
||||||
|
public final class DateUtil {
|
||||||
|
|
||||||
|
public static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.ofPattern("yyyyMMdd");
|
||||||
|
|
||||||
|
private DateUtil() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isValid(String yyyymmdd) {
|
||||||
|
if (yyyymmdd == null || yyyymmdd.length() != 8) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
LocalDate.parse(yyyymmdd, YYYYMMDD);
|
||||||
|
return true;
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LocalDate parse(String yyyymmdd) {
|
||||||
|
if (!isValid(yyyymmdd)) {
|
||||||
|
throw new ValidationException("유효하지 않은 영업일: " + yyyymmdd);
|
||||||
|
}
|
||||||
|
return LocalDate.parse(yyyymmdd, YYYYMMDD);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String format(LocalDate date) {
|
||||||
|
return date.format(YYYYMMDD);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isBusinessDay(LocalDate date) {
|
||||||
|
DayOfWeek dow = date.getDayOfWeek();
|
||||||
|
return dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 해당일 이후 첫 영업일 (해당일이 영업일이면 그대로). */
|
||||||
|
public static LocalDate businessDayOnOrAfter(LocalDate date) {
|
||||||
|
LocalDate d = date;
|
||||||
|
while (!isBusinessDay(d)) {
|
||||||
|
d = d.plusDays(1);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 다음 영업일. */
|
||||||
|
public static LocalDate nextBusinessDay(LocalDate date) {
|
||||||
|
LocalDate d = date.plusDays(1);
|
||||||
|
return businessDayOnOrAfter(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** n 영업일 후의 지급예정일 (정산주기 계산에 사용). */
|
||||||
|
public static LocalDate addBusinessDays(LocalDate date, int n) {
|
||||||
|
LocalDate d = date;
|
||||||
|
int added = 0;
|
||||||
|
while (added < n) {
|
||||||
|
d = d.plusDays(1);
|
||||||
|
if (isBusinessDay(d)) {
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
package com.klaro.acquiring.common.codec;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class MessageCodecTest {
|
||||||
|
|
||||||
|
private final MessageSpec spec = MessageSpec.of("AC_REQ",
|
||||||
|
FieldSpec.an("KEY", 10),
|
||||||
|
FieldSpec.an("MERCHID", 8),
|
||||||
|
FieldSpec.num("AMOUNT", 12),
|
||||||
|
FieldSpec.an("BIZDATE", 8),
|
||||||
|
FieldSpec.an("STATUS", 1));
|
||||||
|
|
||||||
|
private final MessageCodec codec = new MessageCodec(spec);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void encodesFixedLengthWithPaddingRules() {
|
||||||
|
Map<String, String> v = new LinkedHashMap<>();
|
||||||
|
v.put("KEY", "A123");
|
||||||
|
v.put("MERCHID", "M0001");
|
||||||
|
v.put("AMOUNT", "10000");
|
||||||
|
v.put("BIZDATE", "20260717");
|
||||||
|
v.put("STATUS", "R");
|
||||||
|
|
||||||
|
String msg = codec.encode(v);
|
||||||
|
|
||||||
|
assertThat(msg).hasSize(spec.totalLength());
|
||||||
|
assertThat(msg).isEqualTo("A123 " + "M0001 " + "000000010000" + "20260717" + "R");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decodeIsInverseOfEncode() {
|
||||||
|
Map<String, String> v = new LinkedHashMap<>();
|
||||||
|
v.put("KEY", "A123");
|
||||||
|
v.put("MERCHID", "M0001");
|
||||||
|
v.put("AMOUNT", "10000");
|
||||||
|
v.put("BIZDATE", "20260717");
|
||||||
|
v.put("STATUS", "R");
|
||||||
|
|
||||||
|
Map<String, String> decoded = codec.decode(codec.encode(v));
|
||||||
|
|
||||||
|
assertThat(decoded.get("KEY")).isEqualTo("A123");
|
||||||
|
assertThat(decoded.get("MERCHID")).isEqualTo("M0001");
|
||||||
|
assertThat(decoded.get("AMOUNT")).isEqualTo("10000");
|
||||||
|
assertThat(decoded.get("BIZDATE")).isEqualTo("20260717");
|
||||||
|
assertThat(decoded.get("STATUS")).isEqualTo("R");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void numericFieldStripsLeadingZerosOnDecode() {
|
||||||
|
Map<String, String> decoded = codec.decode(codec.encode(Map.of("AMOUNT", "0")));
|
||||||
|
assertThat(decoded.get("AMOUNT")).isEqualTo("0");
|
||||||
|
assertThat(decoded.get("KEY")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTooShortMessage() {
|
||||||
|
assertThatThrownBy(() -> codec.decode("short"))
|
||||||
|
.isInstanceOf(RuntimeException.class)
|
||||||
|
.hasMessageContaining("전문 길이 부족");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package com.klaro.acquiring.common.util;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class DateAmountUtilTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatesYyyymmdd() {
|
||||||
|
assertThat(DateUtil.isValid("20260717")).isTrue();
|
||||||
|
assertThat(DateUtil.isValid("20261317")).isFalse();
|
||||||
|
assertThat(DateUtil.isValid("2026717")).isFalse();
|
||||||
|
assertThat(DateUtil.isValid(null)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void skipsWeekendForBusinessDay() {
|
||||||
|
// 2026-07-17 is a Friday
|
||||||
|
LocalDate friday = LocalDate.of(2026, 7, 17);
|
||||||
|
assertThat(DateUtil.isBusinessDay(friday)).isTrue();
|
||||||
|
// next business day skips Sat/Sun -> Monday 2026-07-20
|
||||||
|
assertThat(DateUtil.nextBusinessDay(friday)).isEqualTo(LocalDate.of(2026, 7, 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addBusinessDaysSkipsWeekends() {
|
||||||
|
LocalDate friday = LocalDate.of(2026, 7, 17);
|
||||||
|
// +2 business days -> Tuesday 2026-07-21
|
||||||
|
assertThat(DateUtil.addBusinessDays(friday, 2)).isEqualTo(LocalDate.of(2026, 7, 21));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void appliesRateWithWonRounding() {
|
||||||
|
BigDecimal fee = AmountUtil.applyRate(AmountUtil.won(10000), new BigDecimal("0.023"));
|
||||||
|
assertThat(fee).isEqualByComparingTo("230");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toleranceComparison() {
|
||||||
|
assertThat(AmountUtil.withinTolerance(new BigDecimal("1000"), new BigDecimal("1005"),
|
||||||
|
new BigDecimal("10"))).isTrue();
|
||||||
|
assertThat(AmountUtil.withinTolerance(new BigDecimal("1000"), new BigDecimal("1050"),
|
||||||
|
new BigDecimal("10"))).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
domain/pom.xml
Normal file
34
domain/pom.xml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>card-acquiring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>domain</artifactId>
|
||||||
|
<name>domain</name>
|
||||||
|
<description>카드 매입·정산 도메인 엔티티/DTO/enum</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>common-framework</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.persistence</groupId>
|
||||||
|
<artifactId>jakarta.persistence-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.validation</groupId>
|
||||||
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.klaro.acquiring.domain.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수수료 계산 결과(분해). 매입 금액에 대해 MDR/VAN 수수료와 순지급액(net)을 담는다.
|
||||||
|
*
|
||||||
|
* @param baseAmount 기준(매입) 금액
|
||||||
|
* @param mdrFee 가맹점 할인수수료
|
||||||
|
* @param vanFee VAN 수수료
|
||||||
|
* @param totalFee 총 수수료 (mdr + van)
|
||||||
|
* @param netAmount 순지급액 (base - totalFee)
|
||||||
|
*/
|
||||||
|
public record FeeBreakdown(BigDecimal baseAmount, BigDecimal mdrFee, BigDecimal vanFee,
|
||||||
|
BigDecimal totalFee, BigDecimal netAmount) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package com.klaro.acquiring.domain.entity;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.enums.TxStatus;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/** 승인 원장(au). 매입/대사의 기준이 되는 승인 거래. */
|
||||||
|
@Entity
|
||||||
|
@Table(name = "approval")
|
||||||
|
public class Approval {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "appr_no", length = 12)
|
||||||
|
private String apprNo;
|
||||||
|
|
||||||
|
@Column(name = "merch_id", length = 15, nullable = false)
|
||||||
|
private String merchId;
|
||||||
|
|
||||||
|
@Column(name = "card_no", length = 19, nullable = false)
|
||||||
|
private String cardNo;
|
||||||
|
|
||||||
|
@Column(name = "amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Column(name = "appr_date", length = 8, nullable = false)
|
||||||
|
private String apprDate;
|
||||||
|
|
||||||
|
@Column(name = "biz_date", nullable = false)
|
||||||
|
private LocalDate bizDate;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", length = 12, nullable = false)
|
||||||
|
private TxStatus status = TxStatus.RECEIVED;
|
||||||
|
|
||||||
|
protected Approval() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Approval(String apprNo, String merchId, String cardNo, BigDecimal amount,
|
||||||
|
String apprDate, LocalDate bizDate) {
|
||||||
|
this.apprNo = apprNo;
|
||||||
|
this.merchId = merchId;
|
||||||
|
this.cardNo = cardNo;
|
||||||
|
this.amount = amount;
|
||||||
|
this.apprDate = apprDate;
|
||||||
|
this.bizDate = bizDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApprNo() { return apprNo; }
|
||||||
|
public void setApprNo(String apprNo) { this.apprNo = apprNo; }
|
||||||
|
public String getMerchId() { return merchId; }
|
||||||
|
public void setMerchId(String merchId) { this.merchId = merchId; }
|
||||||
|
public String getCardNo() { return cardNo; }
|
||||||
|
public void setCardNo(String cardNo) { this.cardNo = cardNo; }
|
||||||
|
public BigDecimal getAmount() { return amount; }
|
||||||
|
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||||
|
public String getApprDate() { return apprDate; }
|
||||||
|
public void setApprDate(String apprDate) { this.apprDate = apprDate; }
|
||||||
|
public LocalDate getBizDate() { return bizDate; }
|
||||||
|
public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; }
|
||||||
|
public TxStatus getStatus() { return status; }
|
||||||
|
public void setStatus(TxStatus status) { this.status = status; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package com.klaro.acquiring.domain.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
/** 카드 마스터(마스킹 카드번호 기준). */
|
||||||
|
@Entity
|
||||||
|
@Table(name = "card")
|
||||||
|
public class Card {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "card_no", length = 19)
|
||||||
|
private String cardNo;
|
||||||
|
|
||||||
|
@Column(name = "bin", length = 6, nullable = false)
|
||||||
|
private String bin;
|
||||||
|
|
||||||
|
@Column(name = "brand", length = 10, nullable = false)
|
||||||
|
private String brand;
|
||||||
|
|
||||||
|
@Column(name = "issuer_code", length = 4)
|
||||||
|
private String issuerCode;
|
||||||
|
|
||||||
|
@Column(name = "status", length = 1, nullable = false)
|
||||||
|
private String status = "A";
|
||||||
|
|
||||||
|
protected Card() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Card(String cardNo, String bin, String brand, String issuerCode) {
|
||||||
|
this.cardNo = cardNo;
|
||||||
|
this.bin = bin;
|
||||||
|
this.brand = brand;
|
||||||
|
this.issuerCode = issuerCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCardNo() { return cardNo; }
|
||||||
|
public void setCardNo(String cardNo) { this.cardNo = cardNo; }
|
||||||
|
public String getBin() { return bin; }
|
||||||
|
public void setBin(String bin) { this.bin = bin; }
|
||||||
|
public String getBrand() { return brand; }
|
||||||
|
public void setBrand(String brand) { this.brand = brand; }
|
||||||
|
public String getIssuerCode() { return issuerCode; }
|
||||||
|
public void setIssuerCode(String issuerCode) { this.issuerCode = issuerCode; }
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
public void setStatus(String status) { this.status = status; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package com.klaro.acquiring.domain.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/** 매입사 입금 통보(대사 3-leg 중 입금 leg). 승인번호 기준 매칭. */
|
||||||
|
@Entity
|
||||||
|
@Table(name = "deposit")
|
||||||
|
public class Deposit {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "deposit_id")
|
||||||
|
private Long depositId;
|
||||||
|
|
||||||
|
@Column(name = "appr_no", length = 12, nullable = false)
|
||||||
|
private String apprNo;
|
||||||
|
|
||||||
|
@Column(name = "merch_id", length = 15, nullable = false)
|
||||||
|
private String merchId;
|
||||||
|
|
||||||
|
@Column(name = "amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Column(name = "biz_date", nullable = false)
|
||||||
|
private LocalDate bizDate;
|
||||||
|
|
||||||
|
protected Deposit() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Deposit(String apprNo, String merchId, BigDecimal amount, LocalDate bizDate) {
|
||||||
|
this.apprNo = apprNo;
|
||||||
|
this.merchId = merchId;
|
||||||
|
this.amount = amount;
|
||||||
|
this.bizDate = bizDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepositId() { return depositId; }
|
||||||
|
public void setDepositId(Long depositId) { this.depositId = depositId; }
|
||||||
|
public String getApprNo() { return apprNo; }
|
||||||
|
public void setApprNo(String apprNo) { this.apprNo = apprNo; }
|
||||||
|
public String getMerchId() { return merchId; }
|
||||||
|
public void setMerchId(String merchId) { this.merchId = merchId; }
|
||||||
|
public BigDecimal getAmount() { return amount; }
|
||||||
|
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||||
|
public LocalDate getBizDate() { return bizDate; }
|
||||||
|
public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
package com.klaro.acquiring.domain.entity;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.enums.FeeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/** 매입 건별 수수료 내역(분해). MDR/VAN 각각 1행. */
|
||||||
|
@Entity
|
||||||
|
@Table(name = "fee")
|
||||||
|
public class Fee {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "fee_id")
|
||||||
|
private Long feeId;
|
||||||
|
|
||||||
|
@Column(name = "purchase_id", nullable = false)
|
||||||
|
private Long purchaseId;
|
||||||
|
|
||||||
|
@Column(name = "merch_id", length = 15, nullable = false)
|
||||||
|
private String merchId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "fee_type", length = 8, nullable = false)
|
||||||
|
private FeeType feeType;
|
||||||
|
|
||||||
|
@Column(name = "base_amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal baseAmount;
|
||||||
|
|
||||||
|
@Column(name = "rate", precision = 6, scale = 4, nullable = false)
|
||||||
|
private BigDecimal rate = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "fee_amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal feeAmount;
|
||||||
|
|
||||||
|
@Column(name = "biz_date", nullable = false)
|
||||||
|
private LocalDate bizDate;
|
||||||
|
|
||||||
|
protected Fee() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Fee(Long purchaseId, String merchId, FeeType feeType, BigDecimal baseAmount,
|
||||||
|
BigDecimal rate, BigDecimal feeAmount, LocalDate bizDate) {
|
||||||
|
this.purchaseId = purchaseId;
|
||||||
|
this.merchId = merchId;
|
||||||
|
this.feeType = feeType;
|
||||||
|
this.baseAmount = baseAmount;
|
||||||
|
this.rate = rate;
|
||||||
|
this.feeAmount = feeAmount;
|
||||||
|
this.bizDate = bizDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getFeeId() { return feeId; }
|
||||||
|
public void setFeeId(Long feeId) { this.feeId = feeId; }
|
||||||
|
public Long getPurchaseId() { return purchaseId; }
|
||||||
|
public void setPurchaseId(Long purchaseId) { this.purchaseId = purchaseId; }
|
||||||
|
public String getMerchId() { return merchId; }
|
||||||
|
public void setMerchId(String merchId) { this.merchId = merchId; }
|
||||||
|
public FeeType getFeeType() { return feeType; }
|
||||||
|
public void setFeeType(FeeType feeType) { this.feeType = feeType; }
|
||||||
|
public BigDecimal getBaseAmount() { return baseAmount; }
|
||||||
|
public void setBaseAmount(BigDecimal baseAmount) { this.baseAmount = baseAmount; }
|
||||||
|
public BigDecimal getRate() { return rate; }
|
||||||
|
public void setRate(BigDecimal rate) { this.rate = rate; }
|
||||||
|
public BigDecimal getFeeAmount() { return feeAmount; }
|
||||||
|
public void setFeeAmount(BigDecimal feeAmount) { this.feeAmount = feeAmount; }
|
||||||
|
public LocalDate getBizDate() { return bizDate; }
|
||||||
|
public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package com.klaro.acquiring.domain.entity;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.enums.DrCr;
|
||||||
|
import com.klaro.acquiring.domain.enums.EntryType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/** 원장 전표(lg). 매입/수수료/정산/지급 반영 및 잔액검증 기준. */
|
||||||
|
@Entity
|
||||||
|
@Table(name = "ledger_entry")
|
||||||
|
public class LedgerEntry {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "entry_id")
|
||||||
|
private Long entryId;
|
||||||
|
|
||||||
|
@Column(name = "merch_id", length = 15, nullable = false)
|
||||||
|
private String merchId;
|
||||||
|
|
||||||
|
@Column(name = "biz_date", nullable = false)
|
||||||
|
private LocalDate bizDate;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "entry_type", length = 12, nullable = false)
|
||||||
|
private EntryType entryType;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "dr_cr", length = 8, nullable = false)
|
||||||
|
private DrCr drCr;
|
||||||
|
|
||||||
|
@Column(name = "amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Column(name = "ref_type", length = 20)
|
||||||
|
private String refType;
|
||||||
|
|
||||||
|
@Column(name = "ref_id", length = 30)
|
||||||
|
private String refId;
|
||||||
|
|
||||||
|
protected LedgerEntry() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public LedgerEntry(String merchId, LocalDate bizDate, EntryType entryType, DrCr drCr,
|
||||||
|
BigDecimal amount, String refType, String refId) {
|
||||||
|
this.merchId = merchId;
|
||||||
|
this.bizDate = bizDate;
|
||||||
|
this.entryType = entryType;
|
||||||
|
this.drCr = drCr;
|
||||||
|
this.amount = amount;
|
||||||
|
this.refType = refType;
|
||||||
|
this.refId = refId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getEntryId() { return entryId; }
|
||||||
|
public void setEntryId(Long entryId) { this.entryId = entryId; }
|
||||||
|
public String getMerchId() { return merchId; }
|
||||||
|
public void setMerchId(String merchId) { this.merchId = merchId; }
|
||||||
|
public LocalDate getBizDate() { return bizDate; }
|
||||||
|
public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; }
|
||||||
|
public EntryType getEntryType() { return entryType; }
|
||||||
|
public void setEntryType(EntryType entryType) { this.entryType = entryType; }
|
||||||
|
public DrCr getDrCr() { return drCr; }
|
||||||
|
public void setDrCr(DrCr drCr) { this.drCr = drCr; }
|
||||||
|
public BigDecimal getAmount() { return amount; }
|
||||||
|
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||||
|
public String getRefType() { return refType; }
|
||||||
|
public void setRefType(String refType) { this.refType = refType; }
|
||||||
|
public String getRefId() { return refId; }
|
||||||
|
public void setRefId(String refId) { this.refId = refId; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
package com.klaro.acquiring.domain.entity;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.enums.MerchantStatus;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가맹점 마스터. 레거시 merchant 테이블 + 수수료율/정산주기/지급계좌 마스터를 통합.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "merchant")
|
||||||
|
public class Merchant {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "merch_id", length = 15)
|
||||||
|
private String merchId;
|
||||||
|
|
||||||
|
@Column(name = "merch_name", length = 60, nullable = false)
|
||||||
|
private String merchName;
|
||||||
|
|
||||||
|
@Column(name = "biz_no", length = 12)
|
||||||
|
private String bizNo;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", length = 12, nullable = false)
|
||||||
|
private MerchantStatus status = MerchantStatus.ACTIVE;
|
||||||
|
|
||||||
|
/** 가맹점 할인수수료율 (MDR), 예: 0.0230 = 2.3% */
|
||||||
|
@Column(name = "mdr_rate", precision = 6, scale = 4, nullable = false)
|
||||||
|
private BigDecimal mdrRate = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
/** VAN 건당 정액 수수료(원) */
|
||||||
|
@Column(name = "van_fee", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal vanFee = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
/** 정산주기 (T+n 영업일) */
|
||||||
|
@Column(name = "settlement_cycle", nullable = false)
|
||||||
|
private int settlementCycle = 2;
|
||||||
|
|
||||||
|
@Column(name = "bank_code", length = 3)
|
||||||
|
private String bankCode;
|
||||||
|
|
||||||
|
@Column(name = "account_no", length = 20)
|
||||||
|
private String accountNo;
|
||||||
|
|
||||||
|
protected Merchant() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Merchant(String merchId, String merchName, BigDecimal mdrRate, BigDecimal vanFee,
|
||||||
|
int settlementCycle, String bankCode, String accountNo) {
|
||||||
|
this.merchId = merchId;
|
||||||
|
this.merchName = merchName;
|
||||||
|
this.mdrRate = mdrRate;
|
||||||
|
this.vanFee = vanFee;
|
||||||
|
this.settlementCycle = settlementCycle;
|
||||||
|
this.bankCode = bankCode;
|
||||||
|
this.accountNo = accountNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMerchId() {
|
||||||
|
return merchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMerchId(String merchId) {
|
||||||
|
this.merchId = merchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMerchName() {
|
||||||
|
return merchName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMerchName(String merchName) {
|
||||||
|
this.merchName = merchName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBizNo() {
|
||||||
|
return bizNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBizNo(String bizNo) {
|
||||||
|
this.bizNo = bizNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MerchantStatus getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(MerchantStatus status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getMdrRate() {
|
||||||
|
return mdrRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMdrRate(BigDecimal mdrRate) {
|
||||||
|
this.mdrRate = mdrRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getVanFee() {
|
||||||
|
return vanFee;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVanFee(BigDecimal vanFee) {
|
||||||
|
this.vanFee = vanFee;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getSettlementCycle() {
|
||||||
|
return settlementCycle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSettlementCycle(int settlementCycle) {
|
||||||
|
this.settlementCycle = settlementCycle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBankCode() {
|
||||||
|
return bankCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBankCode(String bankCode) {
|
||||||
|
this.bankCode = bankCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAccountNo() {
|
||||||
|
return accountNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccountNo(String accountNo) {
|
||||||
|
this.accountNo = accountNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isActive() {
|
||||||
|
return status == MerchantStatus.ACTIVE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package com.klaro.acquiring.domain.entity;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.enums.PaymentStatus;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/** 지급(py). 정산 결과에 대한 가맹점 계좌 지급. */
|
||||||
|
@Entity
|
||||||
|
@Table(name = "payment")
|
||||||
|
public class Payment {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "payment_id")
|
||||||
|
private Long paymentId;
|
||||||
|
|
||||||
|
@Column(name = "settlement_id", nullable = false)
|
||||||
|
private Long settlementId;
|
||||||
|
|
||||||
|
@Column(name = "merch_id", length = 15, nullable = false)
|
||||||
|
private String merchId;
|
||||||
|
|
||||||
|
@Column(name = "pay_date", nullable = false)
|
||||||
|
private LocalDate payDate;
|
||||||
|
|
||||||
|
@Column(name = "amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Column(name = "bank_code", length = 3)
|
||||||
|
private String bankCode;
|
||||||
|
|
||||||
|
@Column(name = "account_no", length = 20)
|
||||||
|
private String accountNo;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", length = 15, nullable = false)
|
||||||
|
private PaymentStatus status = PaymentStatus.READY;
|
||||||
|
|
||||||
|
protected Payment() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Payment(Long settlementId, String merchId, LocalDate payDate, BigDecimal amount,
|
||||||
|
String bankCode, String accountNo) {
|
||||||
|
this.settlementId = settlementId;
|
||||||
|
this.merchId = merchId;
|
||||||
|
this.payDate = payDate;
|
||||||
|
this.amount = amount;
|
||||||
|
this.bankCode = bankCode;
|
||||||
|
this.accountNo = accountNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getPaymentId() { return paymentId; }
|
||||||
|
public void setPaymentId(Long paymentId) { this.paymentId = paymentId; }
|
||||||
|
public Long getSettlementId() { return settlementId; }
|
||||||
|
public void setSettlementId(Long settlementId) { this.settlementId = settlementId; }
|
||||||
|
public String getMerchId() { return merchId; }
|
||||||
|
public void setMerchId(String merchId) { this.merchId = merchId; }
|
||||||
|
public LocalDate getPayDate() { return payDate; }
|
||||||
|
public void setPayDate(LocalDate payDate) { this.payDate = payDate; }
|
||||||
|
public BigDecimal getAmount() { return amount; }
|
||||||
|
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||||
|
public String getBankCode() { return bankCode; }
|
||||||
|
public void setBankCode(String bankCode) { this.bankCode = bankCode; }
|
||||||
|
public String getAccountNo() { return accountNo; }
|
||||||
|
public void setAccountNo(String accountNo) { this.accountNo = accountNo; }
|
||||||
|
public PaymentStatus getStatus() { return status; }
|
||||||
|
public void setStatus(PaymentStatus status) { this.status = status; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package com.klaro.acquiring.domain.entity;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.enums.TxStatus;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/** 매입 원장(ac). 승인 건에 대한 매입 접수/청구 결과 + 수수료 분해. */
|
||||||
|
@Entity
|
||||||
|
@Table(name = "purchase")
|
||||||
|
public class Purchase {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "purchase_id")
|
||||||
|
private Long purchaseId;
|
||||||
|
|
||||||
|
@Column(name = "appr_no", length = 12, nullable = false, unique = true)
|
||||||
|
private String apprNo;
|
||||||
|
|
||||||
|
@Column(name = "merch_id", length = 15, nullable = false)
|
||||||
|
private String merchId;
|
||||||
|
|
||||||
|
@Column(name = "card_no", length = 19, nullable = false)
|
||||||
|
private String cardNo;
|
||||||
|
|
||||||
|
@Column(name = "amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Column(name = "mdr_fee", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal mdrFee = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "van_fee", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal vanFee = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "net_amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal netAmount = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "biz_date", nullable = false)
|
||||||
|
private LocalDate bizDate;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", length = 12, nullable = false)
|
||||||
|
private TxStatus status = TxStatus.RECEIVED;
|
||||||
|
|
||||||
|
protected Purchase() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Purchase(String apprNo, String merchId, String cardNo, BigDecimal amount, LocalDate bizDate) {
|
||||||
|
this.apprNo = apprNo;
|
||||||
|
this.merchId = merchId;
|
||||||
|
this.cardNo = cardNo;
|
||||||
|
this.amount = amount;
|
||||||
|
this.bizDate = bizDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal totalFee() {
|
||||||
|
return mdrFee.add(vanFee);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getPurchaseId() { return purchaseId; }
|
||||||
|
public void setPurchaseId(Long purchaseId) { this.purchaseId = purchaseId; }
|
||||||
|
public String getApprNo() { return apprNo; }
|
||||||
|
public void setApprNo(String apprNo) { this.apprNo = apprNo; }
|
||||||
|
public String getMerchId() { return merchId; }
|
||||||
|
public void setMerchId(String merchId) { this.merchId = merchId; }
|
||||||
|
public String getCardNo() { return cardNo; }
|
||||||
|
public void setCardNo(String cardNo) { this.cardNo = cardNo; }
|
||||||
|
public BigDecimal getAmount() { return amount; }
|
||||||
|
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||||
|
public BigDecimal getMdrFee() { return mdrFee; }
|
||||||
|
public void setMdrFee(BigDecimal mdrFee) { this.mdrFee = mdrFee; }
|
||||||
|
public BigDecimal getVanFee() { return vanFee; }
|
||||||
|
public void setVanFee(BigDecimal vanFee) { this.vanFee = vanFee; }
|
||||||
|
public BigDecimal getNetAmount() { return netAmount; }
|
||||||
|
public void setNetAmount(BigDecimal netAmount) { this.netAmount = netAmount; }
|
||||||
|
public LocalDate getBizDate() { return bizDate; }
|
||||||
|
public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; }
|
||||||
|
public TxStatus getStatus() { return status; }
|
||||||
|
public void setStatus(TxStatus status) { this.status = status; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
package com.klaro.acquiring.domain.entity;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.enums.TxStatus;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/** 대사 결과(rc). 승인-매입-입금 3-way 매칭 결과. */
|
||||||
|
@Entity
|
||||||
|
@Table(name = "reconcile_result")
|
||||||
|
public class ReconcileResult {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "recon_id")
|
||||||
|
private Long reconId;
|
||||||
|
|
||||||
|
@Column(name = "biz_date", nullable = false)
|
||||||
|
private LocalDate bizDate;
|
||||||
|
|
||||||
|
@Column(name = "appr_no", length = 12, nullable = false)
|
||||||
|
private String apprNo;
|
||||||
|
|
||||||
|
@Column(name = "merch_id", length = 15, nullable = false)
|
||||||
|
private String merchId;
|
||||||
|
|
||||||
|
@Column(name = "approval_amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal approvalAmount = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "purchase_amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal purchaseAmount = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "deposit_amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal depositAmount = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "diff_amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal diffAmount = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "matched", nullable = false)
|
||||||
|
private boolean matched;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", length = 12, nullable = false)
|
||||||
|
private TxStatus status = TxStatus.RECEIVED;
|
||||||
|
|
||||||
|
@Column(name = "reason", length = 100)
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
protected ReconcileResult() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReconcileResult(LocalDate bizDate, String apprNo, String merchId) {
|
||||||
|
this.bizDate = bizDate;
|
||||||
|
this.apprNo = apprNo;
|
||||||
|
this.merchId = merchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getReconId() { return reconId; }
|
||||||
|
public void setReconId(Long reconId) { this.reconId = reconId; }
|
||||||
|
public LocalDate getBizDate() { return bizDate; }
|
||||||
|
public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; }
|
||||||
|
public String getApprNo() { return apprNo; }
|
||||||
|
public void setApprNo(String apprNo) { this.apprNo = apprNo; }
|
||||||
|
public String getMerchId() { return merchId; }
|
||||||
|
public void setMerchId(String merchId) { this.merchId = merchId; }
|
||||||
|
public BigDecimal getApprovalAmount() { return approvalAmount; }
|
||||||
|
public void setApprovalAmount(BigDecimal approvalAmount) { this.approvalAmount = approvalAmount; }
|
||||||
|
public BigDecimal getPurchaseAmount() { return purchaseAmount; }
|
||||||
|
public void setPurchaseAmount(BigDecimal purchaseAmount) { this.purchaseAmount = purchaseAmount; }
|
||||||
|
public BigDecimal getDepositAmount() { return depositAmount; }
|
||||||
|
public void setDepositAmount(BigDecimal depositAmount) { this.depositAmount = depositAmount; }
|
||||||
|
public BigDecimal getDiffAmount() { return diffAmount; }
|
||||||
|
public void setDiffAmount(BigDecimal diffAmount) { this.diffAmount = diffAmount; }
|
||||||
|
public boolean isMatched() { return matched; }
|
||||||
|
public void setMatched(boolean matched) { this.matched = matched; }
|
||||||
|
public TxStatus getStatus() { return status; }
|
||||||
|
public void setStatus(TxStatus status) { this.status = status; }
|
||||||
|
public String getReason() { return reason; }
|
||||||
|
public void setReason(String reason) { this.reason = reason; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package com.klaro.acquiring.domain.entity;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.enums.TxStatus;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/** 정산(st). 가맹점·영업일 단위 매입 집계 + netting 결과. */
|
||||||
|
@Entity
|
||||||
|
@Table(name = "settlement",
|
||||||
|
uniqueConstraints = @UniqueConstraint(name = "uq_settlement_merch_date",
|
||||||
|
columnNames = {"merch_id", "biz_date"}))
|
||||||
|
public class Settlement {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "settlement_id")
|
||||||
|
private Long settlementId;
|
||||||
|
|
||||||
|
@Column(name = "merch_id", length = 15, nullable = false)
|
||||||
|
private String merchId;
|
||||||
|
|
||||||
|
@Column(name = "biz_date", nullable = false)
|
||||||
|
private LocalDate bizDate;
|
||||||
|
|
||||||
|
@Column(name = "gross_amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal grossAmount = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "total_fee", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal totalFee = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "net_amount", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal netAmount = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "txn_count", nullable = false)
|
||||||
|
private int txnCount;
|
||||||
|
|
||||||
|
@Column(name = "pay_date", nullable = false)
|
||||||
|
private LocalDate payDate;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", length = 12, nullable = false)
|
||||||
|
private TxStatus status = TxStatus.DONE;
|
||||||
|
|
||||||
|
protected Settlement() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Settlement(String merchId, LocalDate bizDate, LocalDate payDate) {
|
||||||
|
this.merchId = merchId;
|
||||||
|
this.bizDate = bizDate;
|
||||||
|
this.payDate = payDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSettlementId() { return settlementId; }
|
||||||
|
public void setSettlementId(Long settlementId) { this.settlementId = settlementId; }
|
||||||
|
public String getMerchId() { return merchId; }
|
||||||
|
public void setMerchId(String merchId) { this.merchId = merchId; }
|
||||||
|
public LocalDate getBizDate() { return bizDate; }
|
||||||
|
public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; }
|
||||||
|
public BigDecimal getGrossAmount() { return grossAmount; }
|
||||||
|
public void setGrossAmount(BigDecimal grossAmount) { this.grossAmount = grossAmount; }
|
||||||
|
public BigDecimal getTotalFee() { return totalFee; }
|
||||||
|
public void setTotalFee(BigDecimal totalFee) { this.totalFee = totalFee; }
|
||||||
|
public BigDecimal getNetAmount() { return netAmount; }
|
||||||
|
public void setNetAmount(BigDecimal netAmount) { this.netAmount = netAmount; }
|
||||||
|
public int getTxnCount() { return txnCount; }
|
||||||
|
public void setTxnCount(int txnCount) { this.txnCount = txnCount; }
|
||||||
|
public LocalDate getPayDate() { return payDate; }
|
||||||
|
public void setPayDate(LocalDate payDate) { this.payDate = payDate; }
|
||||||
|
public TxStatus getStatus() { return status; }
|
||||||
|
public void setStatus(TxStatus status) { this.status = status; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.klaro.acquiring.domain.enums;
|
||||||
|
|
||||||
|
/** 원장 차변/대변. */
|
||||||
|
public enum DrCr {
|
||||||
|
DEBIT("차변"),
|
||||||
|
CREDIT("대변");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
DrCr(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.klaro.acquiring.domain.enums;
|
||||||
|
|
||||||
|
/** 원장 전표 유형. */
|
||||||
|
public enum EntryType {
|
||||||
|
PURCHASE("매입"),
|
||||||
|
FEE("수수료"),
|
||||||
|
SETTLEMENT("정산"),
|
||||||
|
PAYMENT("지급");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
EntryType(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.klaro.acquiring.domain.enums;
|
||||||
|
|
||||||
|
/** 수수료 유형. 매입 건별 수수료 내역 분해에 사용. */
|
||||||
|
public enum FeeType {
|
||||||
|
MDR("가맹점 할인수수료"),
|
||||||
|
VAN("VAN 수수료");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
FeeType(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.klaro.acquiring.domain.enums;
|
||||||
|
|
||||||
|
/** 가맹점 상태. 레거시 merchant.status(CHAR(1)) 대응. */
|
||||||
|
public enum MerchantStatus {
|
||||||
|
ACTIVE("A", "정상"),
|
||||||
|
SUSPENDED("H", "정지"),
|
||||||
|
CLOSED("C", "해지");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
MerchantStatus(String code, String label) {
|
||||||
|
this.code = code;
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String code() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.klaro.acquiring.domain.enums;
|
||||||
|
|
||||||
|
/** 지급 상태. */
|
||||||
|
public enum PaymentStatus {
|
||||||
|
READY("지급대기"),
|
||||||
|
FILE_CREATED("지급파일생성"),
|
||||||
|
PAID("지급완료"),
|
||||||
|
FAILED("지급실패");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
PaymentStatus(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.klaro.acquiring.domain.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리 상태코드. 레거시 *_core.h 의 상태 상수(R/M/U/S) 대응.
|
||||||
|
*/
|
||||||
|
public enum TxStatus {
|
||||||
|
RECEIVED("R", "접수/수신"),
|
||||||
|
DONE("M", "처리완료"),
|
||||||
|
FAILED("U", "불일치/실패"),
|
||||||
|
SETTLED("S", "정산완료");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
TxStatus(String code, String label) {
|
||||||
|
this.code = code;
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String code() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TxStatus fromCode(String code) {
|
||||||
|
for (TxStatus s : values()) {
|
||||||
|
if (s.code.equals(code)) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("알 수 없는 상태코드: " + code);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
modules/acquiring/pom.xml
Normal file
35
modules/acquiring/pom.xml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>card-acquiring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<relativePath>../../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
<artifactId>acquiring</artifactId>
|
||||||
|
<name>acquiring</name>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>common-framework</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>domain</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>persistence</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
package com.klaro.acquiring.acquiring;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.acquiring.dto.PurchaseCommand;
|
||||||
|
import com.klaro.acquiring.common.error.ErrorCode;
|
||||||
|
import com.klaro.acquiring.common.error.AcquiringException;
|
||||||
|
import com.klaro.acquiring.common.error.NotFoundException;
|
||||||
|
import com.klaro.acquiring.common.error.ValidationException;
|
||||||
|
import com.klaro.acquiring.common.util.AmountUtil;
|
||||||
|
import com.klaro.acquiring.domain.dto.FeeBreakdown;
|
||||||
|
import com.klaro.acquiring.domain.entity.Approval;
|
||||||
|
import com.klaro.acquiring.domain.entity.Fee;
|
||||||
|
import com.klaro.acquiring.domain.entity.Merchant;
|
||||||
|
import com.klaro.acquiring.domain.entity.Purchase;
|
||||||
|
import com.klaro.acquiring.domain.enums.FeeType;
|
||||||
|
import com.klaro.acquiring.domain.enums.TxStatus;
|
||||||
|
import com.klaro.acquiring.persistence.repository.ApprovalRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.FeeRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.MerchantRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.PurchaseRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매입(ac) 접수 서비스. 레거시 ac_ol_* / ac_bt_* 의 매입 처리 로직을 대체한다.
|
||||||
|
* 접수 → 검증(가맹점/승인/중복/금액) → 수수료 계산 → 원장(purchase/fee) 저장.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AcquiringService {
|
||||||
|
|
||||||
|
/** 매입 금액이 승인 금액을 초과할 수 없다(부분매입 허용). 초과 허용오차 0. */
|
||||||
|
private static final BigDecimal CAPTURE_TOLERANCE = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
private final MerchantRepository merchantRepository;
|
||||||
|
private final ApprovalRepository approvalRepository;
|
||||||
|
private final PurchaseRepository purchaseRepository;
|
||||||
|
private final FeeRepository feeRepository;
|
||||||
|
private final FeeCalculator feeCalculator;
|
||||||
|
|
||||||
|
public AcquiringService(MerchantRepository merchantRepository,
|
||||||
|
ApprovalRepository approvalRepository,
|
||||||
|
PurchaseRepository purchaseRepository,
|
||||||
|
FeeRepository feeRepository,
|
||||||
|
FeeCalculator feeCalculator) {
|
||||||
|
this.merchantRepository = merchantRepository;
|
||||||
|
this.approvalRepository = approvalRepository;
|
||||||
|
this.purchaseRepository = purchaseRepository;
|
||||||
|
this.feeRepository = feeRepository;
|
||||||
|
this.feeCalculator = feeCalculator;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Purchase acceptPurchase(PurchaseCommand cmd) {
|
||||||
|
if (cmd == null || cmd.apprNo() == null || cmd.apprNo().isBlank()) {
|
||||||
|
throw new ValidationException("승인번호는 필수입니다");
|
||||||
|
}
|
||||||
|
if (cmd.capturedAmount() == null || cmd.capturedAmount().signum() <= 0) {
|
||||||
|
throw new ValidationException("매입금액은 0보다 커야 합니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
Approval approval = approvalRepository.findByApprNo(cmd.apprNo())
|
||||||
|
.orElseThrow(() -> new NotFoundException("승인 없음: " + cmd.apprNo()));
|
||||||
|
|
||||||
|
if (approval.getStatus() == TxStatus.FAILED) {
|
||||||
|
throw new AcquiringException(ErrorCode.ILLEGAL_STATE,
|
||||||
|
"실패 처리된 승인은 매입할 수 없습니다: " + cmd.apprNo());
|
||||||
|
}
|
||||||
|
if (purchaseRepository.existsByApprNo(cmd.apprNo())) {
|
||||||
|
throw new AcquiringException(ErrorCode.DUPLICATE, "이미 매입된 승인: " + cmd.apprNo());
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal captured = AmountUtil.normalize(cmd.capturedAmount());
|
||||||
|
if (captured.subtract(approval.getAmount()).compareTo(CAPTURE_TOLERANCE) > 0) {
|
||||||
|
throw new ValidationException("매입금액이 승인금액을 초과: 승인=" + approval.getAmount()
|
||||||
|
+ " 매입=" + captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
Merchant merchant = merchantRepository.findById(approval.getMerchId())
|
||||||
|
.orElseThrow(() -> new NotFoundException("가맹점 없음: " + approval.getMerchId()));
|
||||||
|
if (!merchant.isActive()) {
|
||||||
|
throw new AcquiringException(ErrorCode.ILLEGAL_STATE,
|
||||||
|
"비활성 가맹점: " + merchant.getMerchId());
|
||||||
|
}
|
||||||
|
|
||||||
|
FeeBreakdown fee = feeCalculator.calculate(merchant, captured);
|
||||||
|
|
||||||
|
Purchase purchase = new Purchase(approval.getApprNo(), merchant.getMerchId(),
|
||||||
|
approval.getCardNo(), captured, approval.getBizDate());
|
||||||
|
purchase.setMdrFee(fee.mdrFee());
|
||||||
|
purchase.setVanFee(fee.vanFee());
|
||||||
|
purchase.setNetAmount(fee.netAmount());
|
||||||
|
purchase.setStatus(TxStatus.DONE);
|
||||||
|
purchase = purchaseRepository.save(purchase);
|
||||||
|
|
||||||
|
feeRepository.save(new Fee(purchase.getPurchaseId(), merchant.getMerchId(), FeeType.MDR,
|
||||||
|
captured, merchant.getMdrRate(), fee.mdrFee(), purchase.getBizDate()));
|
||||||
|
feeRepository.save(new Fee(purchase.getPurchaseId(), merchant.getMerchId(), FeeType.VAN,
|
||||||
|
captured, BigDecimal.ZERO, fee.vanFee(), purchase.getBizDate()));
|
||||||
|
|
||||||
|
approval.setStatus(TxStatus.DONE);
|
||||||
|
approvalRepository.save(approval);
|
||||||
|
|
||||||
|
return purchase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Purchase getByApprNo(String apprNo) {
|
||||||
|
return purchaseRepository.findByApprNo(apprNo)
|
||||||
|
.orElseThrow(() -> new NotFoundException("매입 없음: " + apprNo));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package com.klaro.acquiring.acquiring;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.util.AmountUtil;
|
||||||
|
import com.klaro.acquiring.domain.dto.FeeBreakdown;
|
||||||
|
import com.klaro.acquiring.domain.entity.Merchant;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매입 수수료 계산기 (st 모듈의 수수료 계약을 매입 접수 시점에 적용).
|
||||||
|
*
|
||||||
|
* <p>수수료 = 가맹점 할인수수료(MDR = 매입금액 × mdrRate) + VAN 수수료(건당 정액).
|
||||||
|
* 순지급액(net) = 매입금액 − 총수수료. 모든 금액은 원 단위 반올림.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class FeeCalculator {
|
||||||
|
|
||||||
|
public FeeBreakdown calculate(Merchant merchant, BigDecimal amount) {
|
||||||
|
BigDecimal base = AmountUtil.normalize(amount);
|
||||||
|
BigDecimal mdrFee = AmountUtil.applyRate(base, merchant.getMdrRate());
|
||||||
|
BigDecimal vanFee = AmountUtil.normalize(merchant.getVanFee());
|
||||||
|
BigDecimal totalFee = mdrFee.add(vanFee);
|
||||||
|
BigDecimal net = base.subtract(totalFee);
|
||||||
|
return new FeeBreakdown(base, mdrFee, vanFee, totalFee, net);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.klaro.acquiring.acquiring.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매입 접수 명령. 승인번호 기준으로 매입을 접수한다.
|
||||||
|
*
|
||||||
|
* @param apprNo 승인번호(매입 대상)
|
||||||
|
* @param capturedAmount 매입(청구) 금액 — 승인금액과 대조
|
||||||
|
*/
|
||||||
|
public record PurchaseCommand(String apprNo, BigDecimal capturedAmount) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package com.klaro.acquiring.acquiring;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.dto.FeeBreakdown;
|
||||||
|
import com.klaro.acquiring.domain.entity.Merchant;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class FeeCalculatorTest {
|
||||||
|
|
||||||
|
private final FeeCalculator calculator = new FeeCalculator();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void computesMdrAndVanFee() {
|
||||||
|
Merchant m = new Merchant("M1", "테스트", new BigDecimal("0.0230"),
|
||||||
|
new BigDecimal("30"), 2, "004", "111");
|
||||||
|
|
||||||
|
FeeBreakdown fb = calculator.calculate(m, new BigDecimal("10000"));
|
||||||
|
|
||||||
|
assertThat(fb.mdrFee()).isEqualByComparingTo("230");
|
||||||
|
assertThat(fb.vanFee()).isEqualByComparingTo("30");
|
||||||
|
assertThat(fb.totalFee()).isEqualByComparingTo("260");
|
||||||
|
assertThat(fb.netAmount()).isEqualByComparingTo("9740");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundsMdrToWon() {
|
||||||
|
Merchant m = new Merchant("M2", "테스트", new BigDecimal("0.0235"),
|
||||||
|
new BigDecimal("0"), 1, "004", "111");
|
||||||
|
// 3333 * 0.0235 = 78.3255 -> 78
|
||||||
|
FeeBreakdown fb = calculator.calculate(m, new BigDecimal("3333"));
|
||||||
|
assertThat(fb.mdrFee()).isEqualByComparingTo("78");
|
||||||
|
assertThat(fb.netAmount()).isEqualByComparingTo("3255");
|
||||||
|
}
|
||||||
|
}
|
||||||
46
modules/closing/pom.xml
Normal file
46
modules/closing/pom.xml
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>card-acquiring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<relativePath>../../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
<artifactId>closing</artifactId>
|
||||||
|
<name>closing</name>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>common-framework</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>domain</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>persistence</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>settlement</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>ledger</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-batch</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package com.klaro.acquiring.closing;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.Settlement;
|
||||||
|
import com.klaro.acquiring.ledger.LedgerService;
|
||||||
|
import com.klaro.acquiring.settlement.SettlementService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마감(cl) 서비스. 일 마감: 당일 정산 집계 + 가맹점별 원장 잔액검증.
|
||||||
|
* 월 마감: 월중 일별 정산 순지급액 합산.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ClosingService {
|
||||||
|
|
||||||
|
private final SettlementService settlementService;
|
||||||
|
private final LedgerService ledgerService;
|
||||||
|
|
||||||
|
public ClosingService(SettlementService settlementService, LedgerService ledgerService) {
|
||||||
|
this.settlementService = settlementService;
|
||||||
|
this.ledgerService = ledgerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 일 마감. 정산 집계 + 원장 잔액검증(불일치 시 예외). */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public DailyClosing closeDaily(LocalDate bizDate) {
|
||||||
|
List<Settlement> settlements = settlementService.listByBizDate(bizDate);
|
||||||
|
BigDecimal gross = BigDecimal.ZERO;
|
||||||
|
BigDecimal fee = BigDecimal.ZERO;
|
||||||
|
BigDecimal net = BigDecimal.ZERO;
|
||||||
|
int txnCount = 0;
|
||||||
|
for (Settlement s : settlements) {
|
||||||
|
gross = gross.add(s.getGrossAmount());
|
||||||
|
fee = fee.add(s.getTotalFee());
|
||||||
|
net = net.add(s.getNetAmount());
|
||||||
|
txnCount += s.getTxnCount();
|
||||||
|
// 원장 반영이 있으면 잔액검증(반영 전이면 0 == 0 통과)
|
||||||
|
ledgerService.verifyBalance(s.getMerchId(), bizDate);
|
||||||
|
}
|
||||||
|
return new DailyClosing(bizDate, settlements.size(), txnCount, gross, fee, net);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 월 마감. 해당 월 각 일자 일마감의 순지급액 합산. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public MonthlyClosing closeMonthly(YearMonth month) {
|
||||||
|
BigDecimal net = BigDecimal.ZERO;
|
||||||
|
BigDecimal gross = BigDecimal.ZERO;
|
||||||
|
BigDecimal fee = BigDecimal.ZERO;
|
||||||
|
int settlementCount = 0;
|
||||||
|
LocalDate d = month.atDay(1);
|
||||||
|
LocalDate end = month.atEndOfMonth();
|
||||||
|
while (!d.isAfter(end)) {
|
||||||
|
List<Settlement> settlements = settlementService.listByBizDate(d);
|
||||||
|
for (Settlement s : settlements) {
|
||||||
|
gross = gross.add(s.getGrossAmount());
|
||||||
|
fee = fee.add(s.getTotalFee());
|
||||||
|
net = net.add(s.getNetAmount());
|
||||||
|
settlementCount++;
|
||||||
|
}
|
||||||
|
d = d.plusDays(1);
|
||||||
|
}
|
||||||
|
return new MonthlyClosing(month, settlementCount, gross, fee, net);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DailyClosing(LocalDate bizDate, int settlementCount, int txnCount,
|
||||||
|
BigDecimal grossAmount, BigDecimal totalFee, BigDecimal netAmount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MonthlyClosing(YearMonth month, int settlementCount,
|
||||||
|
BigDecimal grossAmount, BigDecimal totalFee, BigDecimal netAmount) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.klaro.acquiring.closing.batch;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.closing.ClosingService;
|
||||||
|
import com.klaro.acquiring.common.util.DateUtil;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.Step;
|
||||||
|
import org.springframework.batch.core.configuration.annotation.StepScope;
|
||||||
|
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||||
|
import org.springframework.batch.core.repository.JobRepository;
|
||||||
|
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||||
|
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||||
|
import org.springframework.batch.repeat.RepeatStatus;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일 마감 배치 Job (cl). Tasklet 으로 일 마감 집계·잔액검증을 수행한다.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class ClosingJobConfig {
|
||||||
|
|
||||||
|
public static final String JOB_NAME = "closingJob";
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ClosingJobConfig.class);
|
||||||
|
|
||||||
|
private final ClosingService closingService;
|
||||||
|
|
||||||
|
public ClosingJobConfig(ClosingService closingService) {
|
||||||
|
this.closingService = closingService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@StepScope
|
||||||
|
public Tasklet closingTasklet(@Value("#{jobParameters['bizDate']}") String bizDate) {
|
||||||
|
return (contribution, chunkContext) -> {
|
||||||
|
LocalDate date = DateUtil.parse(bizDate);
|
||||||
|
ClosingService.DailyClosing c = closingService.closeDaily(date);
|
||||||
|
log.info("[cl] 일마감 date={} 정산건수={} 거래건수={} gross={} fee={} net={}",
|
||||||
|
date, c.settlementCount(), c.txnCount(), c.grossAmount(), c.totalFee(), c.netAmount());
|
||||||
|
chunkContext.getStepContext().getStepExecution().getExecutionContext()
|
||||||
|
.put("netAmount", c.netAmount().toString());
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Step closingStep(JobRepository jobRepository,
|
||||||
|
PlatformTransactionManager transactionManager,
|
||||||
|
Tasklet closingTasklet) {
|
||||||
|
return new StepBuilder("closingStep", jobRepository)
|
||||||
|
.tasklet(closingTasklet, transactionManager)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job closingJob(JobRepository jobRepository, Step closingStep) {
|
||||||
|
return new JobBuilder(JOB_NAME, jobRepository)
|
||||||
|
.start(closingStep)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
modules/gateway/pom.xml
Normal file
38
modules/gateway/pom.xml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>card-acquiring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<relativePath>../../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
<artifactId>gateway</artifactId>
|
||||||
|
<name>gateway</name>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>common-framework</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>domain</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>persistence</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>acquiring</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
package com.klaro.acquiring.gateway;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.acquiring.AcquiringService;
|
||||||
|
import com.klaro.acquiring.acquiring.dto.PurchaseCommand;
|
||||||
|
import com.klaro.acquiring.common.codec.FieldSpec;
|
||||||
|
import com.klaro.acquiring.common.codec.MessageCodec;
|
||||||
|
import com.klaro.acquiring.common.codec.MessageSpec;
|
||||||
|
import com.klaro.acquiring.common.error.ErrorCode;
|
||||||
|
import com.klaro.acquiring.common.error.AcquiringException;
|
||||||
|
import com.klaro.acquiring.domain.entity.Purchase;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전문 게이트웨이(mg). 고정길이 전문을 수신·디코드하여 매입 서비스로 위임하고,
|
||||||
|
* 처리 결과를 응답 전문으로 인코드한다. 레거시 mg_ol_* 의 전문 송수신 대체.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class GatewayService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GatewayService.class);
|
||||||
|
|
||||||
|
/** 매입 요청 전문. */
|
||||||
|
public static final MessageSpec REQUEST_SPEC = MessageSpec.of("MG_REQ",
|
||||||
|
FieldSpec.an("TXNCODE", 4),
|
||||||
|
FieldSpec.an("APPRNO", 12),
|
||||||
|
FieldSpec.num("AMOUNT", 15));
|
||||||
|
|
||||||
|
/** 매입 응답 전문. */
|
||||||
|
public static final MessageSpec RESPONSE_SPEC = MessageSpec.of("MG_RES",
|
||||||
|
FieldSpec.an("RESPCODE", 4),
|
||||||
|
FieldSpec.an("APPRNO", 12),
|
||||||
|
FieldSpec.num("NETAMOUNT", 15),
|
||||||
|
FieldSpec.an("MESSAGE", 40));
|
||||||
|
|
||||||
|
/** 매입 접수 거래코드. */
|
||||||
|
public static final String TXN_ACQUIRE = "0210";
|
||||||
|
|
||||||
|
private final MessageCodec requestCodec = new MessageCodec(REQUEST_SPEC);
|
||||||
|
private final MessageCodec responseCodec = new MessageCodec(RESPONSE_SPEC);
|
||||||
|
private final AcquiringService acquiringService;
|
||||||
|
|
||||||
|
public GatewayService(AcquiringService acquiringService) {
|
||||||
|
this.acquiringService = acquiringService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 요청 전문 처리 → 응답 전문. */
|
||||||
|
public String handle(String requestMessage) {
|
||||||
|
Map<String, String> req;
|
||||||
|
try {
|
||||||
|
req = requestCodec.decode(requestMessage);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("[mg] 전문 디코드 실패: {}", e.getMessage());
|
||||||
|
return respond(ErrorCode.INVALID_REQUEST, "", BigDecimal.ZERO, "전문 형식 오류");
|
||||||
|
}
|
||||||
|
|
||||||
|
String txn = req.get("TXNCODE");
|
||||||
|
String apprNo = req.get("APPRNO");
|
||||||
|
if (!TXN_ACQUIRE.equals(txn)) {
|
||||||
|
return respond(ErrorCode.INVALID_REQUEST, apprNo, BigDecimal.ZERO,
|
||||||
|
"미지원 거래코드: " + txn);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
BigDecimal amount = new BigDecimal(req.get("AMOUNT"));
|
||||||
|
Purchase p = acquiringService.acceptPurchase(new PurchaseCommand(apprNo, amount));
|
||||||
|
log.info("[mg] 매입 승인 apprNo={} net={}", apprNo, p.getNetAmount());
|
||||||
|
return respond(ErrorCode.OK, apprNo, p.getNetAmount(), "정상 처리");
|
||||||
|
} catch (AcquiringException e) {
|
||||||
|
log.warn("[mg] 매입 거절 apprNo={} code={} msg={}",
|
||||||
|
apprNo, e.getErrorCode().code(), e.getMessage());
|
||||||
|
return respond(e.getErrorCode(), apprNo, BigDecimal.ZERO, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String respond(ErrorCode code, String apprNo, BigDecimal netAmount, String message) {
|
||||||
|
Map<String, String> res = new LinkedHashMap<>();
|
||||||
|
res.put("RESPCODE", code.code());
|
||||||
|
res.put("APPRNO", apprNo == null ? "" : apprNo);
|
||||||
|
res.put("NETAMOUNT", netAmount.max(BigDecimal.ZERO).toBigInteger().toString());
|
||||||
|
res.put("MESSAGE", message == null ? "" : message);
|
||||||
|
return responseCodec.encode(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageCodec responseCodec() {
|
||||||
|
return responseCodec;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
modules/ledger/pom.xml
Normal file
35
modules/ledger/pom.xml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>card-acquiring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<relativePath>../../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
<artifactId>ledger</artifactId>
|
||||||
|
<name>ledger</name>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>common-framework</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>domain</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>persistence</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
package com.klaro.acquiring.ledger;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.error.ErrorCode;
|
||||||
|
import com.klaro.acquiring.common.error.AcquiringException;
|
||||||
|
import com.klaro.acquiring.common.util.AmountUtil;
|
||||||
|
import com.klaro.acquiring.domain.entity.LedgerEntry;
|
||||||
|
import com.klaro.acquiring.domain.entity.Purchase;
|
||||||
|
import com.klaro.acquiring.domain.enums.DrCr;
|
||||||
|
import com.klaro.acquiring.domain.enums.EntryType;
|
||||||
|
import com.klaro.acquiring.persistence.repository.LedgerEntryRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.PurchaseRepository;
|
||||||
|
import com.klaro.acquiring.domain.enums.TxStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원장(lg) 반영·잔액검증 서비스.
|
||||||
|
*
|
||||||
|
* <p>매입 1건 → 대변(CREDIT, 매입총액) + 차변(DEBIT, 수수료) 전표 2건.
|
||||||
|
* 가맹점·영업일 순잔액 = Σ대변 − Σ차변 = Σ순지급액(net) 이어야 한다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class LedgerService {
|
||||||
|
|
||||||
|
private final LedgerEntryRepository ledgerRepository;
|
||||||
|
private final PurchaseRepository purchaseRepository;
|
||||||
|
|
||||||
|
public LedgerService(LedgerEntryRepository ledgerRepository,
|
||||||
|
PurchaseRepository purchaseRepository) {
|
||||||
|
this.ledgerRepository = ledgerRepository;
|
||||||
|
this.purchaseRepository = purchaseRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 매입 1건을 원장에 반영(전표 2건 생성). */
|
||||||
|
@Transactional
|
||||||
|
public void postPurchase(Purchase p) {
|
||||||
|
ledgerRepository.save(new LedgerEntry(p.getMerchId(), p.getBizDate(), EntryType.PURCHASE,
|
||||||
|
DrCr.CREDIT, p.getAmount(), "PURCHASE", String.valueOf(p.getPurchaseId())));
|
||||||
|
if (p.totalFee().signum() > 0) {
|
||||||
|
ledgerRepository.save(new LedgerEntry(p.getMerchId(), p.getBizDate(), EntryType.FEE,
|
||||||
|
DrCr.DEBIT, p.totalFee(), "PURCHASE", String.valueOf(p.getPurchaseId())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 영업일의 모든 매입완료 건을 원장에 반영하고 반영 건수를 반환. */
|
||||||
|
@Transactional
|
||||||
|
public int reflectDaily(LocalDate bizDate) {
|
||||||
|
List<Purchase> purchases = purchaseRepository.findByBizDateAndStatus(bizDate, TxStatus.DONE);
|
||||||
|
for (Purchase p : purchases) {
|
||||||
|
postPurchase(p);
|
||||||
|
}
|
||||||
|
return purchases.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 가맹점·영업일 순잔액 = Σ대변 − Σ차변. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public BigDecimal balanceOf(String merchId, LocalDate bizDate) {
|
||||||
|
BigDecimal credit = BigDecimal.ZERO;
|
||||||
|
BigDecimal debit = BigDecimal.ZERO;
|
||||||
|
for (LedgerEntry e : ledgerRepository.findByMerchIdAndBizDate(merchId, bizDate)) {
|
||||||
|
if (e.getDrCr() == DrCr.CREDIT) {
|
||||||
|
credit = credit.add(e.getAmount());
|
||||||
|
} else {
|
||||||
|
debit = debit.add(e.getAmount());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return credit.subtract(debit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 잔액검증: 원장 순잔액이 매입 순지급액 합계와 일치하는지 확인.
|
||||||
|
* 불일치 시 예외.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public BigDecimal verifyBalance(String merchId, LocalDate bizDate) {
|
||||||
|
BigDecimal ledgerBalance = balanceOf(merchId, bizDate);
|
||||||
|
BigDecimal expectedNet = purchaseRepository.findByMerchIdAndBizDate(merchId, bizDate).stream()
|
||||||
|
.filter(p -> p.getStatus() == TxStatus.DONE || p.getStatus() == TxStatus.SETTLED)
|
||||||
|
.map(Purchase::getNetAmount)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
if (!AmountUtil.withinTolerance(ledgerBalance, expectedNet, BigDecimal.ZERO)) {
|
||||||
|
throw new AcquiringException(ErrorCode.BALANCE_MISMATCH,
|
||||||
|
"원장 잔액 불일치 merch=" + merchId + " 원장=" + ledgerBalance + " 기대=" + expectedNet);
|
||||||
|
}
|
||||||
|
return ledgerBalance;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
modules/master/pom.xml
Normal file
42
modules/master/pom.xml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>card-acquiring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<relativePath>../../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
<artifactId>master</artifactId>
|
||||||
|
<name>master</name>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>common-framework</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>domain</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>persistence</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package com.klaro.acquiring.master;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.dto.ApiResponse;
|
||||||
|
import com.klaro.acquiring.master.dto.MerchantRequest;
|
||||||
|
import com.klaro.acquiring.master.dto.MerchantResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 가맹점 마스터 REST. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/master/merchants")
|
||||||
|
public class MasterController {
|
||||||
|
|
||||||
|
private final MasterService masterService;
|
||||||
|
|
||||||
|
public MasterController(MasterService masterService) {
|
||||||
|
this.masterService = masterService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<MerchantResponse> register(@Valid @RequestBody MerchantRequest req) {
|
||||||
|
return ApiResponse.ok(MerchantResponse.from(masterService.register(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{merchId}")
|
||||||
|
public ApiResponse<MerchantResponse> update(@PathVariable String merchId,
|
||||||
|
@Valid @RequestBody MerchantRequest req) {
|
||||||
|
return ApiResponse.ok(MerchantResponse.from(masterService.update(merchId, req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{merchId}")
|
||||||
|
public ApiResponse<MerchantResponse> get(@PathVariable String merchId) {
|
||||||
|
return ApiResponse.ok(MerchantResponse.from(masterService.get(merchId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<List<MerchantResponse>> list() {
|
||||||
|
return ApiResponse.ok(masterService.list().stream().map(MerchantResponse::from).toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package com.klaro.acquiring.master;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.error.ErrorCode;
|
||||||
|
import com.klaro.acquiring.common.error.AcquiringException;
|
||||||
|
import com.klaro.acquiring.common.error.NotFoundException;
|
||||||
|
import com.klaro.acquiring.domain.entity.Merchant;
|
||||||
|
import com.klaro.acquiring.master.dto.MerchantRequest;
|
||||||
|
import com.klaro.acquiring.persistence.repository.MerchantRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가맹점/수수료율/한도 마스터(mm) CRUD 서비스.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class MasterService {
|
||||||
|
|
||||||
|
private final MerchantRepository merchantRepository;
|
||||||
|
|
||||||
|
public MasterService(MerchantRepository merchantRepository) {
|
||||||
|
this.merchantRepository = merchantRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Merchant register(MerchantRequest req) {
|
||||||
|
if (merchantRepository.existsById(req.merchId())) {
|
||||||
|
throw new AcquiringException(ErrorCode.DUPLICATE, "이미 존재하는 가맹점: " + req.merchId());
|
||||||
|
}
|
||||||
|
Merchant m = new Merchant(req.merchId(), req.merchName(), req.mdrRate(), req.vanFee(),
|
||||||
|
req.settlementCycle(), req.bankCode(), req.accountNo());
|
||||||
|
m.setBizNo(req.bizNo());
|
||||||
|
return merchantRepository.save(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Merchant update(String merchId, MerchantRequest req) {
|
||||||
|
Merchant m = get(merchId);
|
||||||
|
m.setMerchName(req.merchName());
|
||||||
|
m.setBizNo(req.bizNo());
|
||||||
|
m.setMdrRate(req.mdrRate());
|
||||||
|
m.setVanFee(req.vanFee());
|
||||||
|
m.setSettlementCycle(req.settlementCycle());
|
||||||
|
m.setBankCode(req.bankCode());
|
||||||
|
m.setAccountNo(req.accountNo());
|
||||||
|
return merchantRepository.save(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Merchant get(String merchId) {
|
||||||
|
return merchantRepository.findById(merchId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("가맹점 없음: " + merchId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Merchant> list() {
|
||||||
|
return merchantRepository.findAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.klaro.acquiring.master.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/** 가맹점 등록/수정 요청. */
|
||||||
|
public record MerchantRequest(
|
||||||
|
@NotBlank String merchId,
|
||||||
|
@NotBlank String merchName,
|
||||||
|
String bizNo,
|
||||||
|
@NotNull @DecimalMin("0.0") BigDecimal mdrRate,
|
||||||
|
@NotNull @DecimalMin("0.0") BigDecimal vanFee,
|
||||||
|
@Min(0) int settlementCycle,
|
||||||
|
String bankCode,
|
||||||
|
String accountNo) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.klaro.acquiring.master.dto;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.Merchant;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/** 가맹점 응답. */
|
||||||
|
public record MerchantResponse(String merchId, String merchName, String bizNo, String status,
|
||||||
|
BigDecimal mdrRate, BigDecimal vanFee, int settlementCycle,
|
||||||
|
String bankCode, String accountNo) {
|
||||||
|
|
||||||
|
public static MerchantResponse from(Merchant m) {
|
||||||
|
return new MerchantResponse(m.getMerchId(), m.getMerchName(), m.getBizNo(),
|
||||||
|
m.getStatus().name(), m.getMdrRate(), m.getVanFee(), m.getSettlementCycle(),
|
||||||
|
m.getBankCode(), m.getAccountNo());
|
||||||
|
}
|
||||||
|
}
|
||||||
35
modules/payment/pom.xml
Normal file
35
modules/payment/pom.xml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>card-acquiring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<relativePath>../../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
<artifactId>payment</artifactId>
|
||||||
|
<name>payment</name>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>common-framework</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>domain</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>persistence</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
package com.klaro.acquiring.payment;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.codec.FieldSpec;
|
||||||
|
import com.klaro.acquiring.common.codec.MessageCodec;
|
||||||
|
import com.klaro.acquiring.common.codec.MessageSpec;
|
||||||
|
import com.klaro.acquiring.common.error.NotFoundException;
|
||||||
|
import com.klaro.acquiring.common.util.DateUtil;
|
||||||
|
import com.klaro.acquiring.domain.entity.Merchant;
|
||||||
|
import com.klaro.acquiring.domain.entity.Payment;
|
||||||
|
import com.klaro.acquiring.domain.entity.Settlement;
|
||||||
|
import com.klaro.acquiring.domain.enums.PaymentStatus;
|
||||||
|
import com.klaro.acquiring.persistence.repository.MerchantRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.PaymentRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.SettlementRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지급(py) 서비스. 정산 결과로 지급 파일(고정길이 전문)을 생성하고 지급결과를 반영한다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class PaymentService {
|
||||||
|
|
||||||
|
/** 지급 레코드 전문 레이아웃 (은행 송금 원장 파일 1행). */
|
||||||
|
public static final MessageSpec PAYMENT_SPEC = MessageSpec.of("PAY_REC",
|
||||||
|
FieldSpec.an("BANKCODE", 3),
|
||||||
|
FieldSpec.an("ACCOUNTNO", 20),
|
||||||
|
FieldSpec.num("AMOUNT", 15),
|
||||||
|
FieldSpec.an("MERCHID", 15),
|
||||||
|
FieldSpec.an("PAYDATE", 8));
|
||||||
|
|
||||||
|
private static final MessageCodec CODEC = new MessageCodec(PAYMENT_SPEC);
|
||||||
|
|
||||||
|
private final SettlementRepository settlementRepository;
|
||||||
|
private final MerchantRepository merchantRepository;
|
||||||
|
private final PaymentRepository paymentRepository;
|
||||||
|
|
||||||
|
public PaymentService(SettlementRepository settlementRepository,
|
||||||
|
MerchantRepository merchantRepository,
|
||||||
|
PaymentRepository paymentRepository) {
|
||||||
|
this.settlementRepository = settlementRepository;
|
||||||
|
this.merchantRepository = merchantRepository;
|
||||||
|
this.paymentRepository = paymentRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 해당 지급일의 정산건에 대해 지급 레코드를 생성하고, 고정길이 지급파일 문자열을 반환.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public PaymentFile generateFile(LocalDate payDate) {
|
||||||
|
List<Settlement> settlements = settlementRepository.findAll().stream()
|
||||||
|
.filter(s -> s.getPayDate().equals(payDate))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
StringBuilder file = new StringBuilder();
|
||||||
|
int count = 0;
|
||||||
|
BigDecimal total = BigDecimal.ZERO;
|
||||||
|
for (Settlement s : settlements) {
|
||||||
|
if (s.getNetAmount().signum() <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Merchant m = merchantRepository.findById(s.getMerchId())
|
||||||
|
.orElseThrow(() -> new NotFoundException("가맹점 없음: " + s.getMerchId()));
|
||||||
|
|
||||||
|
Payment payment = new Payment(s.getSettlementId(), m.getMerchId(), payDate,
|
||||||
|
s.getNetAmount(), m.getBankCode(), m.getAccountNo());
|
||||||
|
payment.setStatus(PaymentStatus.FILE_CREATED);
|
||||||
|
paymentRepository.save(payment);
|
||||||
|
|
||||||
|
Map<String, String> rec = new LinkedHashMap<>();
|
||||||
|
rec.put("BANKCODE", m.getBankCode() == null ? "" : m.getBankCode());
|
||||||
|
rec.put("ACCOUNTNO", m.getAccountNo() == null ? "" : m.getAccountNo());
|
||||||
|
rec.put("AMOUNT", s.getNetAmount().toBigInteger().toString());
|
||||||
|
rec.put("MERCHID", m.getMerchId());
|
||||||
|
rec.put("PAYDATE", DateUtil.format(payDate));
|
||||||
|
file.append(CODEC.encode(rec)).append('\n');
|
||||||
|
count++;
|
||||||
|
total = total.add(s.getNetAmount());
|
||||||
|
}
|
||||||
|
return new PaymentFile(payDate, count, total, file.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 지급 결과 반영(성공/실패). */
|
||||||
|
@Transactional
|
||||||
|
public Payment applyResult(Long paymentId, boolean success) {
|
||||||
|
Payment p = paymentRepository.findById(paymentId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("지급 없음: " + paymentId));
|
||||||
|
p.setStatus(success ? PaymentStatus.PAID : PaymentStatus.FAILED);
|
||||||
|
return paymentRepository.save(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Payment> listByPayDate(LocalDate payDate) {
|
||||||
|
return paymentRepository.findByPayDate(payDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 지급 파일 생성 결과. */
|
||||||
|
public record PaymentFile(LocalDate payDate, int count, BigDecimal totalAmount, String content) {
|
||||||
|
}
|
||||||
|
}
|
||||||
38
modules/reconcile/pom.xml
Normal file
38
modules/reconcile/pom.xml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>card-acquiring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<relativePath>../../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
<artifactId>reconcile</artifactId>
|
||||||
|
<name>reconcile</name>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>common-framework</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>domain</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>persistence</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-batch</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package com.klaro.acquiring.reconcile;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.util.AmountUtil;
|
||||||
|
import com.klaro.acquiring.domain.entity.ReconcileResult;
|
||||||
|
import com.klaro.acquiring.domain.enums.TxStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대사(rc) 3-way 매칭기 (순수 로직 — 단위 테스트 대상).
|
||||||
|
*
|
||||||
|
* <p>승인금액 vs 매입금액 vs 입금금액을 허용오차 내에서 비교한다.
|
||||||
|
* 매입/입금 누락 또는 금액 불일치 시 불일치(FAILED)로 판정한다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ReconcileMatcher {
|
||||||
|
|
||||||
|
/** 대사 허용오차(원). 반올림/수수료 조정 흡수용. */
|
||||||
|
public static final BigDecimal DEFAULT_TOLERANCE = new BigDecimal("1");
|
||||||
|
|
||||||
|
public ReconcileResult match(LocalDate bizDate, String apprNo, String merchId,
|
||||||
|
BigDecimal approvalAmount,
|
||||||
|
BigDecimal purchaseAmount, boolean hasPurchase,
|
||||||
|
BigDecimal depositAmount, boolean hasDeposit,
|
||||||
|
BigDecimal tolerance) {
|
||||||
|
ReconcileResult r = new ReconcileResult(bizDate, apprNo, merchId);
|
||||||
|
BigDecimal appr = AmountUtil.nz(approvalAmount);
|
||||||
|
BigDecimal pur = AmountUtil.nz(purchaseAmount);
|
||||||
|
BigDecimal dep = AmountUtil.nz(depositAmount);
|
||||||
|
r.setApprovalAmount(appr);
|
||||||
|
r.setPurchaseAmount(pur);
|
||||||
|
r.setDepositAmount(dep);
|
||||||
|
|
||||||
|
if (!hasPurchase) {
|
||||||
|
r.setMatched(false);
|
||||||
|
r.setStatus(TxStatus.FAILED);
|
||||||
|
r.setReason("매입 누락");
|
||||||
|
r.setDiffAmount(appr);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
if (!hasDeposit) {
|
||||||
|
r.setMatched(false);
|
||||||
|
r.setStatus(TxStatus.FAILED);
|
||||||
|
r.setReason("입금 누락");
|
||||||
|
r.setDiffAmount(appr.subtract(pur).abs());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal d1 = appr.subtract(pur).abs();
|
||||||
|
BigDecimal d2 = pur.subtract(dep).abs();
|
||||||
|
BigDecimal d3 = appr.subtract(dep).abs();
|
||||||
|
BigDecimal maxDiff = d1.max(d2).max(d3);
|
||||||
|
r.setDiffAmount(maxDiff);
|
||||||
|
|
||||||
|
boolean ok = maxDiff.compareTo(AmountUtil.nz(tolerance)) <= 0;
|
||||||
|
if (ok) {
|
||||||
|
r.setMatched(true);
|
||||||
|
r.setStatus(TxStatus.DONE);
|
||||||
|
r.setReason(null);
|
||||||
|
} else {
|
||||||
|
r.setMatched(false);
|
||||||
|
r.setStatus(TxStatus.FAILED);
|
||||||
|
r.setReason("금액 불일치 diff=" + maxDiff);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package com.klaro.acquiring.reconcile;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.Approval;
|
||||||
|
import com.klaro.acquiring.domain.entity.Deposit;
|
||||||
|
import com.klaro.acquiring.domain.entity.Purchase;
|
||||||
|
import com.klaro.acquiring.domain.entity.ReconcileResult;
|
||||||
|
import com.klaro.acquiring.persistence.repository.ApprovalRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.DepositRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.PurchaseRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.ReconcileResultRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대사(rc) 서비스. 승인·매입·입금 원장을 승인번호 기준으로 3-way 대사한다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ReconcileService {
|
||||||
|
|
||||||
|
private final ApprovalRepository approvalRepository;
|
||||||
|
private final PurchaseRepository purchaseRepository;
|
||||||
|
private final DepositRepository depositRepository;
|
||||||
|
private final ReconcileResultRepository reconcileRepository;
|
||||||
|
private final ReconcileMatcher matcher;
|
||||||
|
|
||||||
|
public ReconcileService(ApprovalRepository approvalRepository,
|
||||||
|
PurchaseRepository purchaseRepository,
|
||||||
|
DepositRepository depositRepository,
|
||||||
|
ReconcileResultRepository reconcileRepository,
|
||||||
|
ReconcileMatcher matcher) {
|
||||||
|
this.approvalRepository = approvalRepository;
|
||||||
|
this.purchaseRepository = purchaseRepository;
|
||||||
|
this.depositRepository = depositRepository;
|
||||||
|
this.reconcileRepository = reconcileRepository;
|
||||||
|
this.matcher = matcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단건 대사 결과 계산(저장 없음) — Batch processor 및 테스트에서 사용. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ReconcileResult reconcileOne(Approval approval, BigDecimal tolerance) {
|
||||||
|
Optional<Purchase> purchase = purchaseRepository.findByApprNo(approval.getApprNo());
|
||||||
|
Optional<Deposit> deposit = depositRepository.findByApprNo(approval.getApprNo());
|
||||||
|
return matcher.match(approval.getBizDate(), approval.getApprNo(), approval.getMerchId(),
|
||||||
|
approval.getAmount(),
|
||||||
|
purchase.map(Purchase::getAmount).orElse(BigDecimal.ZERO), purchase.isPresent(),
|
||||||
|
deposit.map(Deposit::getAmount).orElse(BigDecimal.ZERO), deposit.isPresent(),
|
||||||
|
tolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 영업일 전체 대사(계산+저장). 요약 반환. */
|
||||||
|
@Transactional
|
||||||
|
public ReconcileSummary reconcileDaily(LocalDate bizDate, BigDecimal tolerance) {
|
||||||
|
List<Approval> approvals = approvalRepository.findByBizDate(bizDate);
|
||||||
|
long matched = 0;
|
||||||
|
long unmatched = 0;
|
||||||
|
for (Approval a : approvals) {
|
||||||
|
ReconcileResult r = reconcileOne(a, tolerance);
|
||||||
|
reconcileRepository.save(r);
|
||||||
|
if (r.isMatched()) {
|
||||||
|
matched++;
|
||||||
|
} else {
|
||||||
|
unmatched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ReconcileSummary(bizDate, approvals.size(), matched, unmatched);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ReconcileResult> results(LocalDate bizDate) {
|
||||||
|
return reconcileRepository.findByBizDate(bizDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 대사 요약. */
|
||||||
|
public record ReconcileSummary(LocalDate bizDate, long total, long matched, long unmatched) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
package com.klaro.acquiring.reconcile.batch;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.util.DateUtil;
|
||||||
|
import com.klaro.acquiring.domain.entity.Approval;
|
||||||
|
import com.klaro.acquiring.domain.entity.ReconcileResult;
|
||||||
|
import com.klaro.acquiring.persistence.repository.ApprovalRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.ReconcileResultRepository;
|
||||||
|
import com.klaro.acquiring.reconcile.ReconcileMatcher;
|
||||||
|
import com.klaro.acquiring.reconcile.ReconcileService;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.Step;
|
||||||
|
import org.springframework.batch.core.configuration.annotation.StepScope;
|
||||||
|
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||||
|
import org.springframework.batch.core.repository.JobRepository;
|
||||||
|
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||||
|
import org.springframework.batch.item.ItemProcessor;
|
||||||
|
import org.springframework.batch.item.ItemReader;
|
||||||
|
import org.springframework.batch.item.ItemWriter;
|
||||||
|
import org.springframework.batch.item.support.IteratorItemReader;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대사 배치 Job (rc). Reader(승인 커서) → Processor(3-way 매칭) → Writer(대사결과 저장).
|
||||||
|
* 레거시 rc_bt_* 커서 배치 대체.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class ReconcileJobConfig {
|
||||||
|
|
||||||
|
public static final String JOB_NAME = "reconcileJob";
|
||||||
|
|
||||||
|
private final ApprovalRepository approvalRepository;
|
||||||
|
private final ReconcileResultRepository reconcileRepository;
|
||||||
|
private final ReconcileService reconcileService;
|
||||||
|
|
||||||
|
public ReconcileJobConfig(ApprovalRepository approvalRepository,
|
||||||
|
ReconcileResultRepository reconcileRepository,
|
||||||
|
ReconcileService reconcileService) {
|
||||||
|
this.approvalRepository = approvalRepository;
|
||||||
|
this.reconcileRepository = reconcileRepository;
|
||||||
|
this.reconcileService = reconcileService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@StepScope
|
||||||
|
public ItemReader<Approval> reconcileApprovalReader(
|
||||||
|
@Value("#{jobParameters['bizDate']}") String bizDate) {
|
||||||
|
LocalDate date = DateUtil.parse(bizDate);
|
||||||
|
return new IteratorItemReader<>(approvalRepository.findByBizDate(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@StepScope
|
||||||
|
public ItemProcessor<Approval, ReconcileResult> reconcileProcessor() {
|
||||||
|
return approval -> reconcileService.reconcileOne(approval, ReconcileMatcher.DEFAULT_TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ItemWriter<ReconcileResult> reconcileWriter() {
|
||||||
|
return items -> reconcileRepository.saveAll(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Step reconcileStep(JobRepository jobRepository,
|
||||||
|
PlatformTransactionManager transactionManager,
|
||||||
|
ItemReader<Approval> reconcileApprovalReader,
|
||||||
|
ItemProcessor<Approval, ReconcileResult> reconcileProcessor,
|
||||||
|
ItemWriter<ReconcileResult> reconcileWriter) {
|
||||||
|
return new StepBuilder("reconcileStep", jobRepository)
|
||||||
|
.<Approval, ReconcileResult>chunk(20, transactionManager)
|
||||||
|
.reader(reconcileApprovalReader)
|
||||||
|
.processor(reconcileProcessor)
|
||||||
|
.writer(reconcileWriter)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job reconcileJob(JobRepository jobRepository, Step reconcileStep) {
|
||||||
|
return new JobBuilder(JOB_NAME, jobRepository)
|
||||||
|
.start(reconcileStep)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
package com.klaro.acquiring.reconcile;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.ReconcileResult;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ReconcileMatcherTest {
|
||||||
|
|
||||||
|
private final ReconcileMatcher matcher = new ReconcileMatcher();
|
||||||
|
private final LocalDate d = LocalDate.of(2026, 7, 17);
|
||||||
|
private final BigDecimal tol = ReconcileMatcher.DEFAULT_TOLERANCE;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void matchesWhenAllThreeAgreeWithinTolerance() {
|
||||||
|
ReconcileResult r = matcher.match(d, "A1", "M1",
|
||||||
|
new BigDecimal("10000"),
|
||||||
|
new BigDecimal("10000"), true,
|
||||||
|
new BigDecimal("10000"), true, tol);
|
||||||
|
assertThat(r.isMatched()).isTrue();
|
||||||
|
assertThat(r.getDiffAmount()).isEqualByComparingTo("0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toleratesSmallDiff() {
|
||||||
|
ReconcileResult r = matcher.match(d, "A1", "M1",
|
||||||
|
new BigDecimal("10000"),
|
||||||
|
new BigDecimal("10000"), true,
|
||||||
|
new BigDecimal("10001"), true, tol);
|
||||||
|
assertThat(r.isMatched()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flagsPurchaseMissing() {
|
||||||
|
ReconcileResult r = matcher.match(d, "A1", "M1",
|
||||||
|
new BigDecimal("10000"),
|
||||||
|
BigDecimal.ZERO, false,
|
||||||
|
BigDecimal.ZERO, false, tol);
|
||||||
|
assertThat(r.isMatched()).isFalse();
|
||||||
|
assertThat(r.getReason()).isEqualTo("매입 누락");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flagsDepositMissing() {
|
||||||
|
ReconcileResult r = matcher.match(d, "A1", "M1",
|
||||||
|
new BigDecimal("10000"),
|
||||||
|
new BigDecimal("10000"), true,
|
||||||
|
BigDecimal.ZERO, false, tol);
|
||||||
|
assertThat(r.isMatched()).isFalse();
|
||||||
|
assertThat(r.getReason()).isEqualTo("입금 누락");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flagsAmountMismatch() {
|
||||||
|
ReconcileResult r = matcher.match(d, "A1", "M1",
|
||||||
|
new BigDecimal("10000"),
|
||||||
|
new BigDecimal("9000"), true,
|
||||||
|
new BigDecimal("9000"), true, tol);
|
||||||
|
assertThat(r.isMatched()).isFalse();
|
||||||
|
assertThat(r.getReason()).contains("금액 불일치");
|
||||||
|
}
|
||||||
|
}
|
||||||
42
modules/settlement/pom.xml
Normal file
42
modules/settlement/pom.xml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>card-acquiring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<relativePath>../../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
<artifactId>settlement</artifactId>
|
||||||
|
<name>settlement</name>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>common-framework</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>domain</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>persistence</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-batch</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>acquiring</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
package com.klaro.acquiring.settlement;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.error.NotFoundException;
|
||||||
|
import com.klaro.acquiring.common.util.DateUtil;
|
||||||
|
import com.klaro.acquiring.domain.entity.Merchant;
|
||||||
|
import com.klaro.acquiring.domain.entity.Purchase;
|
||||||
|
import com.klaro.acquiring.domain.entity.Settlement;
|
||||||
|
import com.klaro.acquiring.domain.enums.TxStatus;
|
||||||
|
import com.klaro.acquiring.persistence.repository.MerchantRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.PurchaseRepository;
|
||||||
|
import com.klaro.acquiring.persistence.repository.SettlementRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정산(st) 집계·수수료 netting 서비스.
|
||||||
|
*
|
||||||
|
* <p>가맹점·영업일 단위로 매입완료(DONE) 건을 집계한다.
|
||||||
|
* gross = Σ매입금액, totalFee = Σ(MDR+VAN), net = gross − totalFee (netting).
|
||||||
|
* 지급예정일 = 영업일 + 정산주기(T+n 영업일).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SettlementService {
|
||||||
|
|
||||||
|
private final MerchantRepository merchantRepository;
|
||||||
|
private final PurchaseRepository purchaseRepository;
|
||||||
|
private final SettlementRepository settlementRepository;
|
||||||
|
|
||||||
|
public SettlementService(MerchantRepository merchantRepository,
|
||||||
|
PurchaseRepository purchaseRepository,
|
||||||
|
SettlementRepository settlementRepository) {
|
||||||
|
this.merchantRepository = merchantRepository;
|
||||||
|
this.purchaseRepository = purchaseRepository;
|
||||||
|
this.settlementRepository = settlementRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매입완료 건을 집계해 전이(transient) Settlement 를 생성. 대상 건이 없으면 null.
|
||||||
|
* (Batch processor 에서 사용 — 저장은 하지 않는다.)
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Settlement compute(Merchant merchant, LocalDate bizDate) {
|
||||||
|
List<Purchase> purchases =
|
||||||
|
purchaseRepository.findByMerchIdAndBizDateAndStatus(merchant.getMerchId(), bizDate, TxStatus.DONE);
|
||||||
|
if (purchases.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
BigDecimal gross = BigDecimal.ZERO;
|
||||||
|
BigDecimal fee = BigDecimal.ZERO;
|
||||||
|
BigDecimal net = BigDecimal.ZERO;
|
||||||
|
for (Purchase p : purchases) {
|
||||||
|
gross = gross.add(p.getAmount());
|
||||||
|
fee = fee.add(p.totalFee());
|
||||||
|
net = net.add(p.getNetAmount());
|
||||||
|
}
|
||||||
|
LocalDate payDate = DateUtil.addBusinessDays(bizDate, merchant.getSettlementCycle());
|
||||||
|
Settlement s = new Settlement(merchant.getMerchId(), bizDate, payDate);
|
||||||
|
s.setGrossAmount(gross);
|
||||||
|
s.setTotalFee(fee);
|
||||||
|
s.setNetAmount(net);
|
||||||
|
s.setTxnCount(purchases.size());
|
||||||
|
s.setStatus(TxStatus.DONE);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 정산 저장(upsert) + 대상 매입 건 SETTLED 전이. */
|
||||||
|
@Transactional
|
||||||
|
public Settlement save(Settlement computed) {
|
||||||
|
Settlement target = settlementRepository
|
||||||
|
.findByMerchIdAndBizDate(computed.getMerchId(), computed.getBizDate())
|
||||||
|
.orElse(computed);
|
||||||
|
if (target != computed) {
|
||||||
|
target.setGrossAmount(computed.getGrossAmount());
|
||||||
|
target.setTotalFee(computed.getTotalFee());
|
||||||
|
target.setNetAmount(computed.getNetAmount());
|
||||||
|
target.setTxnCount(computed.getTxnCount());
|
||||||
|
target.setPayDate(computed.getPayDate());
|
||||||
|
target.setStatus(TxStatus.DONE);
|
||||||
|
}
|
||||||
|
Settlement saved = settlementRepository.save(target);
|
||||||
|
|
||||||
|
List<Purchase> purchases = purchaseRepository
|
||||||
|
.findByMerchIdAndBizDateAndStatus(computed.getMerchId(), computed.getBizDate(), TxStatus.DONE);
|
||||||
|
for (Purchase p : purchases) {
|
||||||
|
p.setStatus(TxStatus.SETTLED);
|
||||||
|
}
|
||||||
|
purchaseRepository.saveAll(purchases);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단건 정산(계산+저장) — REST/테스트 편의. */
|
||||||
|
@Transactional
|
||||||
|
public Settlement settle(String merchId, LocalDate bizDate) {
|
||||||
|
Merchant merchant = merchantRepository.findById(merchId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("가맹점 없음: " + merchId));
|
||||||
|
Settlement computed = compute(merchant, bizDate);
|
||||||
|
if (computed == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return save(computed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Settlement get(String merchId, LocalDate bizDate) {
|
||||||
|
return settlementRepository.findByMerchIdAndBizDate(merchId, bizDate)
|
||||||
|
.orElseThrow(() -> new NotFoundException(
|
||||||
|
"정산 없음: " + merchId + " " + DateUtil.format(bizDate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Settlement> listByBizDate(LocalDate bizDate) {
|
||||||
|
return settlementRepository.findByBizDate(bizDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package com.klaro.acquiring.settlement.batch;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.common.util.DateUtil;
|
||||||
|
import com.klaro.acquiring.domain.entity.Merchant;
|
||||||
|
import com.klaro.acquiring.domain.entity.Settlement;
|
||||||
|
import com.klaro.acquiring.persistence.repository.MerchantRepository;
|
||||||
|
import com.klaro.acquiring.settlement.SettlementService;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.Step;
|
||||||
|
import org.springframework.batch.core.configuration.annotation.StepScope;
|
||||||
|
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||||
|
import org.springframework.batch.core.repository.JobRepository;
|
||||||
|
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||||
|
import org.springframework.batch.item.ItemProcessor;
|
||||||
|
import org.springframework.batch.item.ItemReader;
|
||||||
|
import org.springframework.batch.item.ItemWriter;
|
||||||
|
import org.springframework.batch.item.support.IteratorItemReader;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정산 배치 Job (st). Reader(가맹점 커서) → Processor(집계·netting) → Writer(정산 저장).
|
||||||
|
* 레거시 st_bt_* 의 커서 배치를 Spring Batch 로 대체.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class SettlementJobConfig {
|
||||||
|
|
||||||
|
public static final String JOB_NAME = "settlementJob";
|
||||||
|
|
||||||
|
private final MerchantRepository merchantRepository;
|
||||||
|
private final SettlementService settlementService;
|
||||||
|
|
||||||
|
public SettlementJobConfig(MerchantRepository merchantRepository,
|
||||||
|
SettlementService settlementService) {
|
||||||
|
this.merchantRepository = merchantRepository;
|
||||||
|
this.settlementService = settlementService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@StepScope
|
||||||
|
public ItemReader<Merchant> settlementMerchantReader() {
|
||||||
|
return new IteratorItemReader<>(merchantRepository.findAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@StepScope
|
||||||
|
public ItemProcessor<Merchant, Settlement> settlementProcessor(
|
||||||
|
@Value("#{jobParameters['bizDate']}") String bizDate) {
|
||||||
|
LocalDate date = DateUtil.parse(bizDate);
|
||||||
|
return merchant -> settlementService.compute(merchant, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ItemWriter<Settlement> settlementWriter() {
|
||||||
|
return items -> {
|
||||||
|
for (Settlement s : items) {
|
||||||
|
settlementService.save(s);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Step settlementStep(JobRepository jobRepository,
|
||||||
|
PlatformTransactionManager transactionManager,
|
||||||
|
ItemReader<Merchant> settlementMerchantReader,
|
||||||
|
ItemProcessor<Merchant, Settlement> settlementProcessor,
|
||||||
|
ItemWriter<Settlement> settlementWriter) {
|
||||||
|
return new StepBuilder("settlementStep", jobRepository)
|
||||||
|
.<Merchant, Settlement>chunk(10, transactionManager)
|
||||||
|
.reader(settlementMerchantReader)
|
||||||
|
.processor(settlementProcessor)
|
||||||
|
.writer(settlementWriter)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job settlementJob(JobRepository jobRepository, Step settlementStep) {
|
||||||
|
return new JobBuilder(JOB_NAME, jobRepository)
|
||||||
|
.start(settlementStep)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
persistence/pom.xml
Normal file
38
persistence/pom.xml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>card-acquiring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>persistence</artifactId>
|
||||||
|
<name>persistence</name>
|
||||||
|
<description>Spring Data JPA 리포지토리 + Flyway 스키마 + H2</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>domain</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.klaro.acquiring.persistence.repository;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.Approval;
|
||||||
|
import com.klaro.acquiring.domain.enums.TxStatus;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface ApprovalRepository extends JpaRepository<Approval, String> {
|
||||||
|
Optional<Approval> findByApprNo(String apprNo);
|
||||||
|
List<Approval> findByBizDateAndStatus(LocalDate bizDate, TxStatus status);
|
||||||
|
List<Approval> findByBizDate(LocalDate bizDate);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.klaro.acquiring.persistence.repository;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.Card;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface CardRepository extends JpaRepository<Card, String> {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.klaro.acquiring.persistence.repository;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.Deposit;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface DepositRepository extends JpaRepository<Deposit, Long> {
|
||||||
|
Optional<Deposit> findByApprNo(String apprNo);
|
||||||
|
List<Deposit> findByBizDate(LocalDate bizDate);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.klaro.acquiring.persistence.repository;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.Fee;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface FeeRepository extends JpaRepository<Fee, Long> {
|
||||||
|
List<Fee> findByPurchaseId(Long purchaseId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.klaro.acquiring.persistence.repository;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.LedgerEntry;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface LedgerEntryRepository extends JpaRepository<LedgerEntry, Long> {
|
||||||
|
List<LedgerEntry> findByMerchIdAndBizDate(String merchId, LocalDate bizDate);
|
||||||
|
List<LedgerEntry> findByBizDate(LocalDate bizDate);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.klaro.acquiring.persistence.repository;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.Merchant;
|
||||||
|
import com.klaro.acquiring.domain.enums.MerchantStatus;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface MerchantRepository extends JpaRepository<Merchant, String> {
|
||||||
|
List<Merchant> findByStatus(MerchantStatus status);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.klaro.acquiring.persistence.repository;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.Payment;
|
||||||
|
import com.klaro.acquiring.domain.enums.PaymentStatus;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface PaymentRepository extends JpaRepository<Payment, Long> {
|
||||||
|
List<Payment> findByPayDate(LocalDate payDate);
|
||||||
|
List<Payment> findByPayDateAndStatus(LocalDate payDate, PaymentStatus status);
|
||||||
|
List<Payment> findByMerchId(String merchId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package com.klaro.acquiring.persistence.repository;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.Purchase;
|
||||||
|
import com.klaro.acquiring.domain.enums.TxStatus;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface PurchaseRepository extends JpaRepository<Purchase, Long> {
|
||||||
|
boolean existsByApprNo(String apprNo);
|
||||||
|
Optional<Purchase> findByApprNo(String apprNo);
|
||||||
|
List<Purchase> findByBizDateAndStatus(LocalDate bizDate, TxStatus status);
|
||||||
|
List<Purchase> findByMerchIdAndBizDateAndStatus(String merchId, LocalDate bizDate, TxStatus status);
|
||||||
|
List<Purchase> findByMerchIdAndBizDate(String merchId, LocalDate bizDate);
|
||||||
|
List<Purchase> findByBizDate(LocalDate bizDate);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.klaro.acquiring.persistence.repository;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.ReconcileResult;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface ReconcileResultRepository extends JpaRepository<ReconcileResult, Long> {
|
||||||
|
List<ReconcileResult> findByBizDate(LocalDate bizDate);
|
||||||
|
List<ReconcileResult> findByBizDateAndMatched(LocalDate bizDate, boolean matched);
|
||||||
|
Optional<ReconcileResult> findByBizDateAndApprNo(LocalDate bizDate, String apprNo);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.klaro.acquiring.persistence.repository;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.Settlement;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface SettlementRepository extends JpaRepository<Settlement, Long> {
|
||||||
|
Optional<Settlement> findByMerchIdAndBizDate(String merchId, LocalDate bizDate);
|
||||||
|
List<Settlement> findByBizDate(LocalDate bizDate);
|
||||||
|
List<Settlement> findByMerchId(String merchId);
|
||||||
|
}
|
||||||
135
persistence/src/main/resources/db/migration/V1__schema.sql
Normal file
135
persistence/src/main/resources/db/migration/V1__schema.sql
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
-- V1__schema.sql
|
||||||
|
-- 카드 매입·정산 스키마. 레거시 acquire-core-full/db/schema.sql + 모듈별 *_ledger 를
|
||||||
|
-- 정규화하여 재구성. 상태코드는 enum name(VARCHAR)으로 저장(RECEIVED/DONE/FAILED/SETTLED).
|
||||||
|
-- H2/PostgreSQL 공통 호환 DDL.
|
||||||
|
|
||||||
|
CREATE TABLE merchant (
|
||||||
|
merch_id VARCHAR(15) NOT NULL,
|
||||||
|
merch_name VARCHAR(60) NOT NULL,
|
||||||
|
biz_no VARCHAR(12),
|
||||||
|
status VARCHAR(12) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
mdr_rate DECIMAL(6,4) NOT NULL DEFAULT 0,
|
||||||
|
van_fee DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
settlement_cycle INT NOT NULL DEFAULT 2,
|
||||||
|
bank_code VARCHAR(3),
|
||||||
|
account_no VARCHAR(20),
|
||||||
|
CONSTRAINT pk_merchant PRIMARY KEY (merch_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE card (
|
||||||
|
card_no VARCHAR(19) NOT NULL,
|
||||||
|
bin VARCHAR(6) NOT NULL,
|
||||||
|
brand VARCHAR(10) NOT NULL,
|
||||||
|
issuer_code VARCHAR(4),
|
||||||
|
status VARCHAR(1) NOT NULL DEFAULT 'A',
|
||||||
|
CONSTRAINT pk_card PRIMARY KEY (card_no)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE approval (
|
||||||
|
appr_no VARCHAR(12) NOT NULL,
|
||||||
|
merch_id VARCHAR(15) NOT NULL,
|
||||||
|
card_no VARCHAR(19) NOT NULL,
|
||||||
|
amount DECIMAL(18,2) NOT NULL,
|
||||||
|
appr_date VARCHAR(8) NOT NULL,
|
||||||
|
biz_date DATE NOT NULL,
|
||||||
|
status VARCHAR(12) NOT NULL DEFAULT 'RECEIVED',
|
||||||
|
CONSTRAINT pk_approval PRIMARY KEY (appr_no)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_approval_biz ON approval (biz_date, status);
|
||||||
|
|
||||||
|
CREATE TABLE purchase (
|
||||||
|
purchase_id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
appr_no VARCHAR(12) NOT NULL,
|
||||||
|
merch_id VARCHAR(15) NOT NULL,
|
||||||
|
card_no VARCHAR(19) NOT NULL,
|
||||||
|
amount DECIMAL(18,2) NOT NULL,
|
||||||
|
mdr_fee DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
van_fee DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
net_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
biz_date DATE NOT NULL,
|
||||||
|
status VARCHAR(12) NOT NULL DEFAULT 'RECEIVED',
|
||||||
|
CONSTRAINT pk_purchase PRIMARY KEY (purchase_id),
|
||||||
|
CONSTRAINT uq_purchase_appr UNIQUE (appr_no)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_purchase_biz ON purchase (biz_date, status);
|
||||||
|
CREATE INDEX ix_purchase_merch ON purchase (merch_id, biz_date);
|
||||||
|
|
||||||
|
CREATE TABLE deposit (
|
||||||
|
deposit_id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
appr_no VARCHAR(12) NOT NULL,
|
||||||
|
merch_id VARCHAR(15) NOT NULL,
|
||||||
|
amount DECIMAL(18,2) NOT NULL,
|
||||||
|
biz_date DATE NOT NULL,
|
||||||
|
CONSTRAINT pk_deposit PRIMARY KEY (deposit_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_deposit_appr ON deposit (appr_no);
|
||||||
|
|
||||||
|
CREATE TABLE fee (
|
||||||
|
fee_id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
purchase_id BIGINT NOT NULL,
|
||||||
|
merch_id VARCHAR(15) NOT NULL,
|
||||||
|
fee_type VARCHAR(8) NOT NULL,
|
||||||
|
base_amount DECIMAL(18,2) NOT NULL,
|
||||||
|
rate DECIMAL(6,4) NOT NULL DEFAULT 0,
|
||||||
|
fee_amount DECIMAL(18,2) NOT NULL,
|
||||||
|
biz_date DATE NOT NULL,
|
||||||
|
CONSTRAINT pk_fee PRIMARY KEY (fee_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_fee_purchase ON fee (purchase_id);
|
||||||
|
|
||||||
|
CREATE TABLE reconcile_result (
|
||||||
|
recon_id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
biz_date DATE NOT NULL,
|
||||||
|
appr_no VARCHAR(12) NOT NULL,
|
||||||
|
merch_id VARCHAR(15) NOT NULL,
|
||||||
|
approval_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
purchase_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
deposit_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
diff_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
matched BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
status VARCHAR(12) NOT NULL DEFAULT 'RECEIVED',
|
||||||
|
reason VARCHAR(100),
|
||||||
|
CONSTRAINT pk_reconcile PRIMARY KEY (recon_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_reconcile_biz ON reconcile_result (biz_date);
|
||||||
|
|
||||||
|
CREATE TABLE settlement (
|
||||||
|
settlement_id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
merch_id VARCHAR(15) NOT NULL,
|
||||||
|
biz_date DATE NOT NULL,
|
||||||
|
gross_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
total_fee DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
net_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
txn_count INT NOT NULL DEFAULT 0,
|
||||||
|
pay_date DATE NOT NULL,
|
||||||
|
status VARCHAR(12) NOT NULL DEFAULT 'DONE',
|
||||||
|
CONSTRAINT pk_settlement PRIMARY KEY (settlement_id),
|
||||||
|
CONSTRAINT uq_settlement_merch_date UNIQUE (merch_id, biz_date)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_settlement_biz ON settlement (biz_date);
|
||||||
|
|
||||||
|
CREATE TABLE ledger_entry (
|
||||||
|
entry_id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
merch_id VARCHAR(15) NOT NULL,
|
||||||
|
biz_date DATE NOT NULL,
|
||||||
|
entry_type VARCHAR(12) NOT NULL,
|
||||||
|
dr_cr VARCHAR(8) NOT NULL,
|
||||||
|
amount DECIMAL(18,2) NOT NULL,
|
||||||
|
ref_type VARCHAR(20),
|
||||||
|
ref_id VARCHAR(30),
|
||||||
|
CONSTRAINT pk_ledger_entry PRIMARY KEY (entry_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_ledger_merch ON ledger_entry (merch_id, biz_date);
|
||||||
|
|
||||||
|
CREATE TABLE payment (
|
||||||
|
payment_id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
settlement_id BIGINT NOT NULL,
|
||||||
|
merch_id VARCHAR(15) NOT NULL,
|
||||||
|
pay_date DATE NOT NULL,
|
||||||
|
amount DECIMAL(18,2) NOT NULL,
|
||||||
|
bank_code VARCHAR(3),
|
||||||
|
account_no VARCHAR(20),
|
||||||
|
status VARCHAR(15) NOT NULL DEFAULT 'READY',
|
||||||
|
CONSTRAINT pk_payment PRIMARY KEY (payment_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_payment_paydate ON payment (pay_date, status);
|
||||||
12
persistence/src/main/resources/db/migration/V2__seed.sql
Normal file
12
persistence/src/main/resources/db/migration/V2__seed.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- V2__seed.sql - 최소 참조/시드 데이터
|
||||||
|
-- 가맹점 3개(수수료율/정산주기/지급계좌 상이), 카드 3개.
|
||||||
|
|
||||||
|
INSERT INTO merchant (merch_id, merch_name, biz_no, status, mdr_rate, van_fee, settlement_cycle, bank_code, account_no) VALUES
|
||||||
|
('M0000000000001', '한빛마트', '111-11-11111', 'ACTIVE', 0.0230, 30.00, 2, '004', '11122233344455'),
|
||||||
|
('M0000000000002', '가온카페', '222-22-22222', 'ACTIVE', 0.0180, 25.00, 3, '088', '22233344455566'),
|
||||||
|
('M0000000000003', '다올주유소', '333-33-33333', 'ACTIVE', 0.0150, 20.00, 1, '020', '33344455566677');
|
||||||
|
|
||||||
|
INSERT INTO card (card_no, bin, brand, issuer_code, status) VALUES
|
||||||
|
('123456******7890', '123456', 'LOCAL', '0301', 'A'),
|
||||||
|
('654321******0987', '654321', 'VISA', '0302', 'A'),
|
||||||
|
('987654******3210', '987654', 'MASTER','0303', 'A');
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.klaro.acquiring.persistence;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringBootConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
|
||||||
|
/** persistence 모듈 슬라이스 테스트용 부트 설정. */
|
||||||
|
@SpringBootConfiguration
|
||||||
|
@EnableAutoConfiguration
|
||||||
|
@EntityScan(basePackages = "com.klaro.acquiring.domain.entity")
|
||||||
|
@EnableJpaRepositories(basePackages = "com.klaro.acquiring.persistence.repository")
|
||||||
|
public class PersistenceTestApp {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package com.klaro.acquiring.persistence.repository;
|
||||||
|
|
||||||
|
import com.klaro.acquiring.domain.entity.Approval;
|
||||||
|
import com.klaro.acquiring.domain.entity.Merchant;
|
||||||
|
import com.klaro.acquiring.domain.enums.MerchantStatus;
|
||||||
|
import com.klaro.acquiring.domain.enums.TxStatus;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
class MerchantRepositoryTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MerchantRepository merchantRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ApprovalRepository approvalRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flywaySeedLoadsThreeMerchants() {
|
||||||
|
List<Merchant> active = merchantRepository.findByStatus(MerchantStatus.ACTIVE);
|
||||||
|
assertThat(active).hasSize(3);
|
||||||
|
Merchant m = merchantRepository.findById("M0000000000001").orElseThrow();
|
||||||
|
assertThat(m.getMdrRate()).isEqualByComparingTo("0.0230");
|
||||||
|
assertThat(m.getSettlementCycle()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistsAndQueriesApprovalByBizDate() {
|
||||||
|
LocalDate bizDate = LocalDate.of(2026, 7, 17);
|
||||||
|
approvalRepository.save(new Approval("A0001", "M0000000000001", "123456******7890",
|
||||||
|
new BigDecimal("10000.00"), "20260717", bizDate));
|
||||||
|
|
||||||
|
List<Approval> found = approvalRepository.findByBizDateAndStatus(bizDate, TxStatus.RECEIVED);
|
||||||
|
assertThat(found).extracting(Approval::getApprNo).containsExactly("A0001");
|
||||||
|
}
|
||||||
|
}
|
||||||
15
persistence/src/test/resources/application.yml
Normal file
15
persistence/src/test/resources/application.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:h2:mem:persist;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
username: sa
|
||||||
|
password: ""
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: none
|
||||||
|
open-in-view: false
|
||||||
|
properties:
|
||||||
|
hibernate.format_sql: false
|
||||||
|
flyway:
|
||||||
|
enabled: true
|
||||||
|
locations: classpath:db/migration
|
||||||
115
pom.xml
Normal file
115
pom.xml
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?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 http://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.3.5</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>card-acquiring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<name>card-acquiring-boot</name>
|
||||||
|
<description>카드 매입·정산 시스템 (acquire-core 레거시의 Spring Boot 이관 타깃)</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<maven.compiler.release>17</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<modules>
|
||||||
|
<module>common-framework</module>
|
||||||
|
<module>domain</module>
|
||||||
|
<module>persistence</module>
|
||||||
|
<module>modules/gateway</module>
|
||||||
|
<module>modules/acquiring</module>
|
||||||
|
<module>modules/reconcile</module>
|
||||||
|
<module>modules/settlement</module>
|
||||||
|
<module>modules/payment</module>
|
||||||
|
<module>modules/ledger</module>
|
||||||
|
<module>modules/master</module>
|
||||||
|
<module>modules/closing</module>
|
||||||
|
<module>app</module>
|
||||||
|
</modules>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>common-framework</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>domain</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>persistence</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>gateway</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>acquiring</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>reconcile</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>settlement</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>payment</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>ledger</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>master</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.klaro.acquiring</groupId>
|
||||||
|
<artifactId>closing</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<pluginManagement>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<skip>true</skip>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</pluginManagement>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
Reference in a new issue