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
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();
|
||||
}
|
||||
}
|
||||
Reference in a new issue