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

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

View file

@ -0,0 +1,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");
}
}