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