feat: complete Spring Boot 카드 매입·정산 system (acquire-core migration target, mvn verify green, 24 tests)

This commit is contained in:
forge-bot 2026-07-18 15:27:41 +00:00
commit 0f6e3acfe6
92 changed files with 4320 additions and 0 deletions

View 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>

View file

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

View file

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