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