feat(api): TaxCalculator 도메인 + PaymentService 세금 통합 + AgentService 세무 매핑 (도메인 P0-1-c)

P0-1 세무 처리 마무리. ga-batch 정산 step 통합은 도메인 P0 마무리 단계.

TaxCalculator (신규, ga-api/service/tax):
- @Component pure function: TaxBreakdown calculate(amount, taxType, vatType)
- record TaxBreakdown {incomeTax, localTax, vat, netAmount}
- 세율은 SystemConfigService로 외부화 (TAX_RATE_BUSINESS_INCOME 등)
- 공식:
  - incomeTax = amount × incomeRate / 100 (ROUND_HALF_UP, scale 0)
  - localTax = incomeTax × localRate / 100
  - vat = (TAXABLE) ? amount × vatRate / 100 : 0
  - netAmount = amount - incomeTax - localTax + vat

PaymentService:
- calculateTaxes(paymentId) — 단건 재계산 @Transactional
- calculateTaxesForMonth(settleMonth) — 월별 일괄 @Transactional + batch update

PaymentMapper (ga-core):
- updateTaxes / updateTaxesBatch / selectBySettleMonth 추가

AgentService.toVO():
- taxType/vatType/businessNo 매핑 + default 처리
- 사업소득자인데 businessNo 미입력 시 경고 로그

PaymentController:
- POST /api/payments/{id}/calculate-tax (@RequirePermission, @DataChangeLog)
- POST /api/payments/calculate-taxes?settleMonth=YYYYMM
This commit is contained in:
GA Pro
2026-05-13 00:00:13 +09:00
parent 92f32175ad
commit f8c7d8459b
6 changed files with 159 additions and 0 deletions
@@ -48,4 +48,23 @@ public class PaymentController {
@RequestParam String bankCode) {
return ApiResponse.ok(paymentService.generateTransferFile(settleMonth, bankCode));
}
@Operation(summary = "세금 단건 재계산")
@PostMapping("/{paymentId}/calculate-tax")
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
@DataChangeLog(menu = "PAYMENT", table = "payment")
public ApiResponse<Void> calculateTax(@PathVariable Long paymentId) {
paymentService.calculateTaxes(paymentId);
return ApiResponse.ok();
}
@Operation(summary = "세금 월별 일괄 재계산")
@PostMapping("/calculate-taxes")
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
@DataChangeLog(menu = "PAYMENT", table = "payment")
public ApiResponse<Void> calculateTaxesForMonth(
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth) {
paymentService.calculateTaxesForMonth(settleMonth);
return ApiResponse.ok();
}
}
@@ -7,11 +7,15 @@ import com.ga.common.model.PageResponse;
import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.vo.org.*;
import com.ga.core.vo.org.AgentStatus;
import com.ga.core.vo.org.TaxType;
import com.ga.core.vo.org.VatType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.cursor.Cursor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class AgentService {
@@ -102,6 +106,17 @@ public class AgentService {
vo.setBankCode(req.getBankCode());
vo.setAccountNo(req.getAccountNo());
vo.setJoinDate(req.getJoinDate());
String taxType = req.getTaxType() != null ? req.getTaxType() : TaxType.BUSINESS_INCOME.name();
String vatType = req.getVatType() != null ? req.getVatType() : VatType.NONE.name();
vo.setTaxType(taxType);
vo.setVatType(vatType);
vo.setBusinessNo(req.getBusinessNo());
if (TaxType.BUSINESS_INCOME.name().equals(taxType)
&& (req.getBusinessNo() == null || req.getBusinessNo().isBlank())) {
log.warn("agentId={} taxType=BUSINESS_INCOME이지만 businessNo가 비어있습니다", agentId);
}
return vo;
}
}
@@ -1,21 +1,36 @@
package com.ga.api.service.settle;
import com.ga.api.service.tax.TaxBreakdown;
import com.ga.api.service.tax.TaxCalculator;
import com.ga.api.service.transfer.TransferFileService;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.mapper.settle.PaymentMapper;
import com.ga.core.vo.org.AgentVO;
import com.ga.core.vo.org.TaxType;
import com.ga.core.vo.org.VatType;
import com.ga.core.vo.settle.PaymentResp;
import com.ga.core.vo.settle.PaymentVO;
import com.ga.core.vo.settle.SettleSearchParam;
import com.ga.core.vo.settle.TransferFileResp;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentMapper mapper;
private final AgentMapper agentMapper;
private final TransferFileService transferFileService;
private final TaxCalculator taxCalculator;
@Transactional(readOnly = true)
public PageResponse<PaymentResp> list(SettleSearchParam param) {
@@ -31,4 +46,55 @@ public class PaymentService {
public TransferFileResp generateTransferFile(String settleMonth, String bankCode) {
return transferFileService.generate(settleMonth, bankCode);
}
@Transactional
public void calculateTaxes(Long paymentId) {
PaymentVO payment = mapper.selectById(paymentId);
if (payment == null) throw new BizException(ErrorCode.NOT_FOUND);
applyTaxes(payment);
mapper.updateTaxes(payment.getPaymentId(),
payment.getIncomeTaxAmount(), payment.getLocalTaxAmount(),
payment.getVatAmount(), payment.getNetAmount());
}
@Transactional
public void calculateTaxesForMonth(String settleMonth) {
List<PaymentVO> payments = mapper.selectBySettleMonth(settleMonth);
for (PaymentVO payment : payments) {
applyTaxes(payment);
}
if (!payments.isEmpty()) {
mapper.updateTaxesBatch(payments);
}
}
private void applyTaxes(PaymentVO payment) {
AgentVO agent = agentMapper.selectById(payment.getAgentId());
TaxType taxType = parseTaxType(agent);
VatType vatType = parseVatType(agent);
if (taxType == TaxType.BUSINESS_INCOME && (agent == null || agent.getBusinessNo() == null || agent.getBusinessNo().isBlank())) {
log.warn("agentId={} taxType=BUSINESS_INCOME이지만 businessNo가 비어있습니다", payment.getAgentId());
}
TaxBreakdown breakdown = taxCalculator.calculate(payment.getPayAmount(), taxType, vatType);
payment.setIncomeTaxAmount(breakdown.incomeTax());
payment.setLocalTaxAmount(breakdown.localTax());
payment.setVatAmount(breakdown.vat());
payment.setNetAmount(breakdown.netAmount());
}
private TaxType parseTaxType(AgentVO agent) {
if (agent != null && agent.getTaxType() != null) {
try { return TaxType.valueOf(agent.getTaxType()); } catch (IllegalArgumentException ignored) {}
}
return TaxType.BUSINESS_INCOME;
}
private VatType parseVatType(AgentVO agent) {
if (agent != null && agent.getVatType() != null) {
try { return VatType.valueOf(agent.getVatType()); } catch (IllegalArgumentException ignored) {}
}
return VatType.NONE;
}
}
@@ -0,0 +1,10 @@
package com.ga.api.service.tax;
import java.math.BigDecimal;
public record TaxBreakdown(
BigDecimal incomeTax,
BigDecimal localTax,
BigDecimal vat,
BigDecimal netAmount
) {}
@@ -0,0 +1,41 @@
package com.ga.api.service.tax;
import com.ga.common.system.SystemConfigService;
import com.ga.core.vo.org.TaxType;
import com.ga.core.vo.org.VatType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Component
@RequiredArgsConstructor
public class TaxCalculator {
private final SystemConfigService configService;
public TaxBreakdown calculate(BigDecimal amount, TaxType taxType, VatType vatType) {
BigDecimal incomeRate = resolveIncomeRate(taxType);
BigDecimal localRate = configService.getDecimal("TAX_RATE_LOCAL_INCOME", new BigDecimal("10.0"));
BigDecimal vatRate = configService.getDecimal("TAX_RATE_VAT", new BigDecimal("10.0"));
BigDecimal incomeTax = amount.multiply(incomeRate)
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
BigDecimal localTax = incomeTax.multiply(localRate)
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
BigDecimal vat = VatType.TAXABLE.equals(vatType)
? amount.multiply(vatRate).divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
: BigDecimal.ZERO;
BigDecimal netAmount = amount.subtract(incomeTax).subtract(localTax).add(vat);
return new TaxBreakdown(incomeTax, localTax, vat, netAmount);
}
private BigDecimal resolveIncomeRate(TaxType taxType) {
if (TaxType.OTHER_INCOME.equals(taxType)) {
return configService.getDecimal("TAX_RATE_OTHER_INCOME", new BigDecimal("8.8"));
}
return configService.getDecimal("TAX_RATE_BUSINESS_INCOME", new BigDecimal("3.3"));
}
}