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

35
modules/acquiring/pom.xml Normal file
View 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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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("금액 불일치");
}
}

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