commit 0f6e3acfe66672d9ac010d9eda40cf1bfdfe259b Author: forge-bot Date: Sat Jul 18 15:27:41 2026 +0000 feat: complete Spring Boot 카드 매입·정산 system (acquire-core migration target, mvn verify green, 24 tests) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7307206 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +*.class +.idea/ diff --git a/app/pom.xml b/app/pom.xml new file mode 100644 index 0000000..1eefac3 --- /dev/null +++ b/app/pom.xml @@ -0,0 +1,91 @@ + + + 4.0.0 + + com.klaro.acquiring + card-acquiring-boot + 1.0.0 + + app + app + Spring Boot 부트 모듈: 전 모듈 조립 + REST + 배치 런처 + + + + com.klaro.acquiring + gateway + + + com.klaro.acquiring + acquiring + + + com.klaro.acquiring + reconcile + + + com.klaro.acquiring + settlement + + + com.klaro.acquiring + payment + + + com.klaro.acquiring + ledger + + + com.klaro.acquiring + master + + + com.klaro.acquiring + closing + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-batch + + + com.h2database + h2 + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.batch + spring-batch-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + false + com.klaro.acquiring.app.CardAcquiringApplication + + + + + diff --git a/app/src/main/java/com/klaro/acquiring/app/CardAcquiringApplication.java b/app/src/main/java/com/klaro/acquiring/app/CardAcquiringApplication.java new file mode 100644 index 0000000..81c4739 --- /dev/null +++ b/app/src/main/java/com/klaro/acquiring/app/CardAcquiringApplication.java @@ -0,0 +1,19 @@ +package com.klaro.acquiring.app; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * 카드 매입·정산 시스템 부트 애플리케이션. 전 모듈(mg/ac/rc/vl/st/py/lg/mm/cm/cl)을 조립한다. + */ +@SpringBootApplication(scanBasePackages = "com.klaro.acquiring") +@EntityScan(basePackages = "com.klaro.acquiring.domain.entity") +@EnableJpaRepositories(basePackages = "com.klaro.acquiring.persistence.repository") +public class CardAcquiringApplication { + + public static void main(String[] args) { + SpringApplication.run(CardAcquiringApplication.class, args); + } +} diff --git a/app/src/main/java/com/klaro/acquiring/app/web/AcquiringController.java b/app/src/main/java/com/klaro/acquiring/app/web/AcquiringController.java new file mode 100644 index 0000000..a719346 --- /dev/null +++ b/app/src/main/java/com/klaro/acquiring/app/web/AcquiringController.java @@ -0,0 +1,47 @@ +package com.klaro.acquiring.app.web; + +import com.klaro.acquiring.acquiring.AcquiringService; +import com.klaro.acquiring.acquiring.dto.PurchaseCommand; +import com.klaro.acquiring.common.dto.ApiResponse; +import com.klaro.acquiring.domain.entity.Purchase; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.math.BigDecimal; + +/** 매입(ac) 온라인 REST. */ +@RestController +@RequestMapping("/api/acquiring/purchases") +public class AcquiringController { + + private final AcquiringService acquiringService; + + public AcquiringController(AcquiringService acquiringService) { + this.acquiringService = acquiringService; + } + + @PostMapping + public ApiResponse accept(@Valid @RequestBody PurchaseAcceptRequest req) { + Purchase p = acquiringService.acceptPurchase(new PurchaseCommand(req.apprNo(), req.amount())); + return ApiResponse.ok(PurchaseView.from(p)); + } + + @GetMapping("/{apprNo}") + public ApiResponse get(@PathVariable String apprNo) { + return ApiResponse.ok(PurchaseView.from(acquiringService.getByApprNo(apprNo))); + } + + public record PurchaseView(Long purchaseId, String apprNo, String merchId, BigDecimal amount, + BigDecimal mdrFee, BigDecimal vanFee, BigDecimal netAmount, + String status) { + static PurchaseView from(Purchase p) { + return new PurchaseView(p.getPurchaseId(), p.getApprNo(), p.getMerchId(), p.getAmount(), + p.getMdrFee(), p.getVanFee(), p.getNetAmount(), p.getStatus().name()); + } + } +} diff --git a/app/src/main/java/com/klaro/acquiring/app/web/BatchController.java b/app/src/main/java/com/klaro/acquiring/app/web/BatchController.java new file mode 100644 index 0000000..2b2a825 --- /dev/null +++ b/app/src/main/java/com/klaro/acquiring/app/web/BatchController.java @@ -0,0 +1,58 @@ +package com.klaro.acquiring.app.web; + +import com.klaro.acquiring.common.dto.ApiResponse; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 배치 잡 런처 REST (대사/정산/마감). */ +@RestController +@RequestMapping("/api/batch") +public class BatchController { + + private final JobLauncher jobLauncher; + private final Job reconcileJob; + private final Job settlementJob; + private final Job closingJob; + + public BatchController(JobLauncher jobLauncher, + @Qualifier("reconcileJob") Job reconcileJob, + @Qualifier("settlementJob") Job settlementJob, + @Qualifier("closingJob") Job closingJob) { + this.jobLauncher = jobLauncher; + this.reconcileJob = reconcileJob; + this.settlementJob = settlementJob; + this.closingJob = closingJob; + } + + @PostMapping("/reconcile") + public ApiResponse reconcile(@RequestParam String bizDate) throws Exception { + return run(reconcileJob, bizDate); + } + + @PostMapping("/settlement") + public ApiResponse settlement(@RequestParam String bizDate) throws Exception { + return run(settlementJob, bizDate); + } + + @PostMapping("/closing") + public ApiResponse closing(@RequestParam String bizDate) throws Exception { + return run(closingJob, bizDate); + } + + private ApiResponse run(Job job, String bizDate) throws Exception { + JobParameters params = new JobParametersBuilder() + .addString("bizDate", bizDate) + .addLong("ts", System.currentTimeMillis()) + .toJobParameters(); + JobExecution exec = jobLauncher.run(job, params); + return ApiResponse.ok(exec.getStatus().name()); + } +} diff --git a/app/src/main/java/com/klaro/acquiring/app/web/GatewayController.java b/app/src/main/java/com/klaro/acquiring/app/web/GatewayController.java new file mode 100644 index 0000000..8c2255c --- /dev/null +++ b/app/src/main/java/com/klaro/acquiring/app/web/GatewayController.java @@ -0,0 +1,26 @@ +package com.klaro.acquiring.app.web; + +import com.klaro.acquiring.gateway.GatewayService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 전문 게이트웨이(mg) REST. 고정길이 전문 in/out(text/plain). */ +@RestController +@RequestMapping("/api/gateway") +public class GatewayController { + + private final GatewayService gatewayService; + + public GatewayController(GatewayService gatewayService) { + this.gatewayService = gatewayService; + } + + @PostMapping(value = "/message", consumes = MediaType.TEXT_PLAIN_VALUE, + produces = MediaType.TEXT_PLAIN_VALUE) + public String message(@RequestBody String message) { + return gatewayService.handle(message); + } +} diff --git a/app/src/main/java/com/klaro/acquiring/app/web/GlobalExceptionHandler.java b/app/src/main/java/com/klaro/acquiring/app/web/GlobalExceptionHandler.java new file mode 100644 index 0000000..9197c84 --- /dev/null +++ b/app/src/main/java/com/klaro/acquiring/app/web/GlobalExceptionHandler.java @@ -0,0 +1,45 @@ +package com.klaro.acquiring.app.web; + +import com.klaro.acquiring.common.dto.ApiResponse; +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 org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** 도메인 예외 → 표준 응답 매핑. */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity> handleNotFound(NotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(e.getErrorCode(), e.getMessage())); + } + + @ExceptionHandler(ValidationException.class) + public ResponseEntity> handleValidation(ValidationException e) { + return ResponseEntity.badRequest() + .body(ApiResponse.error(e.getErrorCode(), e.getMessage())); + } + + @ExceptionHandler(AcquiringException.class) + public ResponseEntity> handleDomain(AcquiringException e) { + return ResponseEntity.unprocessableEntity() + .body(ApiResponse.error(e.getErrorCode(), e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleBeanValidation(MethodArgumentNotValidException e) { + String msg = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(f -> f.getField() + ": " + f.getDefaultMessage()) + .orElse("요청값 오류"); + return ResponseEntity.badRequest() + .body(ApiResponse.error(ErrorCode.INVALID_REQUEST, msg)); + } +} diff --git a/app/src/main/java/com/klaro/acquiring/app/web/PurchaseAcceptRequest.java b/app/src/main/java/com/klaro/acquiring/app/web/PurchaseAcceptRequest.java new file mode 100644 index 0000000..f686e38 --- /dev/null +++ b/app/src/main/java/com/klaro/acquiring/app/web/PurchaseAcceptRequest.java @@ -0,0 +1,13 @@ +package com.klaro.acquiring.app.web; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; + +/** 매입 접수 REST 요청. */ +public record PurchaseAcceptRequest( + @NotBlank String apprNo, + @NotNull @DecimalMin(value = "0.01") BigDecimal amount) { +} diff --git a/app/src/main/java/com/klaro/acquiring/app/web/SettlementController.java b/app/src/main/java/com/klaro/acquiring/app/web/SettlementController.java new file mode 100644 index 0000000..dcbbaad --- /dev/null +++ b/app/src/main/java/com/klaro/acquiring/app/web/SettlementController.java @@ -0,0 +1,51 @@ +package com.klaro.acquiring.app.web; + +import com.klaro.acquiring.common.dto.ApiResponse; +import com.klaro.acquiring.common.util.DateUtil; +import com.klaro.acquiring.domain.entity.Settlement; +import com.klaro.acquiring.settlement.SettlementService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** 정산(st) 조회 REST. */ +@RestController +@RequestMapping("/api/settlement") +public class SettlementController { + + private final SettlementService settlementService; + + public SettlementController(SettlementService settlementService) { + this.settlementService = settlementService; + } + + @GetMapping + public ApiResponse> byBizDate(@RequestParam String bizDate) { + LocalDate date = DateUtil.parse(bizDate); + return ApiResponse.ok(settlementService.listByBizDate(date).stream() + .map(SettlementView::from).toList()); + } + + @GetMapping("/merchant") + public ApiResponse byMerchant(@RequestParam String merchId, + @RequestParam String bizDate) { + LocalDate date = DateUtil.parse(bizDate); + return ApiResponse.ok(SettlementView.from(settlementService.get(merchId, date))); + } + + public record SettlementView(Long settlementId, String merchId, String bizDate, + BigDecimal grossAmount, BigDecimal totalFee, BigDecimal netAmount, + int txnCount, String payDate, String status) { + static SettlementView from(Settlement s) { + return new SettlementView(s.getSettlementId(), s.getMerchId(), + DateUtil.format(s.getBizDate()), s.getGrossAmount(), s.getTotalFee(), + s.getNetAmount(), s.getTxnCount(), DateUtil.format(s.getPayDate()), + s.getStatus().name()); + } + } +} diff --git a/app/src/main/resources/application.yml b/app/src/main/resources/application.yml new file mode 100644 index 0000000..ed13da3 --- /dev/null +++ b/app/src/main/resources/application.yml @@ -0,0 +1,32 @@ +spring: + application: + name: card-acquiring-boot + datasource: + url: jdbc:h2:mem:acquiring;DB_CLOSE_DELAY=-1;MODE=PostgreSQL + driver-class-name: org.h2.Driver + username: sa + password: "" + jpa: + hibernate: + ddl-auto: none + open-in-view: false + properties: + hibernate.jdbc.time_zone: Asia/Seoul + flyway: + enabled: true + locations: classpath:db/migration + batch: + job: + enabled: false + jdbc: + initialize-schema: always + h2: + console: + enabled: true + +server: + port: 8080 + +logging: + level: + com.klaro.acquiring: INFO diff --git a/app/src/test/java/com/klaro/acquiring/app/AcquiringServiceTest.java b/app/src/test/java/com/klaro/acquiring/app/AcquiringServiceTest.java new file mode 100644 index 0000000..02f1749 --- /dev/null +++ b/app/src/test/java/com/klaro/acquiring/app/AcquiringServiceTest.java @@ -0,0 +1,77 @@ +package com.klaro.acquiring.app; + +import com.klaro.acquiring.acquiring.AcquiringService; +import com.klaro.acquiring.acquiring.dto.PurchaseCommand; +import com.klaro.acquiring.common.error.AcquiringException; +import com.klaro.acquiring.domain.entity.Approval; +import com.klaro.acquiring.domain.entity.Purchase; +import com.klaro.acquiring.domain.enums.TxStatus; +import com.klaro.acquiring.persistence.repository.ApprovalRepository; +import com.klaro.acquiring.persistence.repository.FeeRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** 매입 서비스 + 리포지토리 슬라이스(@SpringBootTest, H2). */ +@SpringBootTest +@Transactional +class AcquiringServiceTest { + + @Autowired + private AcquiringService acquiringService; + @Autowired + private ApprovalRepository approvalRepository; + @Autowired + private FeeRepository feeRepository; + + @Test + void acceptsPurchaseAndComputesFee() { + LocalDate bizDate = LocalDate.of(2026, 7, 17); + approvalRepository.save(new Approval("IT-A-0001", "M0000000000001", "123456******7890", + new BigDecimal("10000.00"), "20260717", bizDate)); + + Purchase p = acquiringService.acceptPurchase(new PurchaseCommand("IT-A-0001", + new BigDecimal("10000.00"))); + + // MDR 2.3% = 230, VAN 30 => fee 260, net 9740 + assertThat(p.getMdrFee()).isEqualByComparingTo("230"); + assertThat(p.getVanFee()).isEqualByComparingTo("30"); + assertThat(p.getNetAmount()).isEqualByComparingTo("9740"); + assertThat(p.getStatus()).isEqualTo(TxStatus.DONE); + assertThat(feeRepository.findByPurchaseId(p.getPurchaseId())).hasSize(2); + assertThat(approvalRepository.findByApprNo("IT-A-0001").orElseThrow().getStatus()) + .isEqualTo(TxStatus.DONE); + } + + @Test + void rejectsDuplicatePurchase() { + LocalDate bizDate = LocalDate.of(2026, 7, 17); + approvalRepository.save(new Approval("IT-A-0002", "M0000000000001", "123456******7890", + new BigDecimal("5000.00"), "20260717", bizDate)); + acquiringService.acceptPurchase(new PurchaseCommand("IT-A-0002", new BigDecimal("5000.00"))); + + assertThatThrownBy(() -> acquiringService.acceptPurchase( + new PurchaseCommand("IT-A-0002", new BigDecimal("5000.00")))) + .isInstanceOf(AcquiringException.class) + .hasMessageContaining("이미 매입"); + } + + @Test + void rejectsCaptureOverApproval() { + LocalDate bizDate = LocalDate.of(2026, 7, 17); + approvalRepository.save(new Approval("IT-A-0003", "M0000000000001", "123456******7890", + new BigDecimal("5000.00"), "20260717", bizDate)); + + assertThatThrownBy(() -> acquiringService.acceptPurchase( + new PurchaseCommand("IT-A-0003", new BigDecimal("9000.00")))) + .isInstanceOf(AcquiringException.class) + .hasMessageContaining("초과"); + } +} diff --git a/app/src/test/java/com/klaro/acquiring/app/CardAcquiringApplicationTests.java b/app/src/test/java/com/klaro/acquiring/app/CardAcquiringApplicationTests.java new file mode 100644 index 0000000..d611f9d --- /dev/null +++ b/app/src/test/java/com/klaro/acquiring/app/CardAcquiringApplicationTests.java @@ -0,0 +1,13 @@ +package com.klaro.acquiring.app; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +/** 전 모듈 조립(ApplicationContext) 로드 검증. */ +@SpringBootTest +class CardAcquiringApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/app/src/test/java/com/klaro/acquiring/app/ReconcileJobTest.java b/app/src/test/java/com/klaro/acquiring/app/ReconcileJobTest.java new file mode 100644 index 0000000..40cf2dc --- /dev/null +++ b/app/src/test/java/com/klaro/acquiring/app/ReconcileJobTest.java @@ -0,0 +1,80 @@ +package com.klaro.acquiring.app; + +import com.klaro.acquiring.acquiring.AcquiringService; +import com.klaro.acquiring.acquiring.dto.PurchaseCommand; +import com.klaro.acquiring.domain.entity.Approval; +import com.klaro.acquiring.domain.entity.Deposit; +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.ReconcileResultRepository; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 대사 배치 Job 테스트: 매칭 1건 + 불일치(입금누락) 1건. */ +@SpringBootTest +@SpringBatchTest +class ReconcileJobTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + @Autowired + @Qualifier("reconcileJob") + private Job reconcileJob; + @Autowired + private AcquiringService acquiringService; + @Autowired + private ApprovalRepository approvalRepository; + @Autowired + private DepositRepository depositRepository; + @Autowired + private ReconcileResultRepository reconcileRepository; + + @Test + void reconcilesThreeWayMatchAndDetectsMismatch() throws Exception { + LocalDate bizDate = LocalDate.of(2026, 7, 21); + // 정상 매칭 건: 승인=매입=입금=10000 + approvalRepository.save(new Approval("IT-R-0001", "M0000000000001", "123456******7890", + new BigDecimal("10000.00"), "20260721", bizDate)); + acquiringService.acceptPurchase(new PurchaseCommand("IT-R-0001", new BigDecimal("10000.00"))); + depositRepository.save(new Deposit("IT-R-0001", "M0000000000001", + new BigDecimal("10000.00"), bizDate)); + // 불일치 건: 매입은 있으나 입금 누락 + approvalRepository.save(new Approval("IT-R-0002", "M0000000000001", "123456******7890", + new BigDecimal("5000.00"), "20260721", bizDate)); + acquiringService.acceptPurchase(new PurchaseCommand("IT-R-0002", new BigDecimal("5000.00"))); + + jobLauncherTestUtils.setJob(reconcileJob); + JobParameters params = new JobParametersBuilder() + .addString("bizDate", "20260721") + .addLong("ts", System.currentTimeMillis()) + .toJobParameters(); + JobExecution exec = jobLauncherTestUtils.launchJob(params); + + assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED); + List results = reconcileRepository.findByBizDate(bizDate); + assertThat(results).hasSize(2); + ReconcileResult matched = reconcileRepository + .findByBizDateAndApprNo(bizDate, "IT-R-0001").orElseThrow(); + assertThat(matched.isMatched()).isTrue(); + ReconcileResult mismatch = reconcileRepository + .findByBizDateAndApprNo(bizDate, "IT-R-0002").orElseThrow(); + assertThat(mismatch.isMatched()).isFalse(); + assertThat(mismatch.getReason()).contains("입금 누락"); + } +} diff --git a/app/src/test/java/com/klaro/acquiring/app/SettlementJobTest.java b/app/src/test/java/com/klaro/acquiring/app/SettlementJobTest.java new file mode 100644 index 0000000..ced9026 --- /dev/null +++ b/app/src/test/java/com/klaro/acquiring/app/SettlementJobTest.java @@ -0,0 +1,72 @@ +package com.klaro.acquiring.app; + +import com.klaro.acquiring.acquiring.AcquiringService; +import com.klaro.acquiring.acquiring.dto.PurchaseCommand; +import com.klaro.acquiring.domain.entity.Approval; +import com.klaro.acquiring.domain.entity.Settlement; +import com.klaro.acquiring.persistence.repository.ApprovalRepository; +import com.klaro.acquiring.persistence.repository.SettlementRepository; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 정산 배치 Job 테스트(JobLauncherTestUtils). */ +@SpringBootTest +@SpringBatchTest +class SettlementJobTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + @Autowired + @Qualifier("settlementJob") + private Job settlementJob; + @Autowired + private AcquiringService acquiringService; + @Autowired + private ApprovalRepository approvalRepository; + @Autowired + private SettlementRepository settlementRepository; + + @Test + void runsSettlementJobAndAggregatesNet() throws Exception { + LocalDate bizDate = LocalDate.of(2026, 7, 20); + // 두 건 매입 -> 정산 대상 + approvalRepository.save(new Approval("IT-S-0001", "M0000000000001", "123456******7890", + new BigDecimal("10000.00"), "20260720", bizDate)); + approvalRepository.save(new Approval("IT-S-0002", "M0000000000001", "123456******7890", + new BigDecimal("20000.00"), "20260720", bizDate)); + acquiringService.acceptPurchase(new PurchaseCommand("IT-S-0001", new BigDecimal("10000.00"))); + acquiringService.acceptPurchase(new PurchaseCommand("IT-S-0002", new BigDecimal("20000.00"))); + + jobLauncherTestUtils.setJob(settlementJob); + JobParameters params = new JobParametersBuilder() + .addString("bizDate", "20260720") + .addLong("ts", System.currentTimeMillis()) + .toJobParameters(); + JobExecution exec = jobLauncherTestUtils.launchJob(params); + + assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED); + Settlement s = settlementRepository + .findByMerchIdAndBizDate("M0000000000001", bizDate).orElseThrow(); + // gross 30000, MDR 2.3% => 690, VAN 30*2=60, fee 750, net 29250 + assertThat(s.getGrossAmount()).isEqualByComparingTo("30000"); + assertThat(s.getTotalFee()).isEqualByComparingTo("750"); + assertThat(s.getNetAmount()).isEqualByComparingTo("29250"); + assertThat(s.getTxnCount()).isEqualTo(2); + // 정산주기 T+2 영업일: 2026-07-20(월) +2 => 2026-07-22(수) + assertThat(s.getPayDate()).isEqualTo(LocalDate.of(2026, 7, 22)); + } +} diff --git a/common-framework/pom.xml b/common-framework/pom.xml new file mode 100644 index 0000000..116da52 --- /dev/null +++ b/common-framework/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + com.klaro.acquiring + card-acquiring-boot + 1.0.0 + + common-framework + common-framework + TxCore 프레임워크의 Spring 대체: TxContext, MessageCodec(전문 codec), TxTemplate, 유틸, 예외체계 + + + + org.springframework + spring-context + + + org.springframework + spring-tx + + + org.slf4j + slf4j-api + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/common-framework/src/main/java/com/klaro/acquiring/common/codec/FieldSpec.java b/common-framework/src/main/java/com/klaro/acquiring/common/codec/FieldSpec.java new file mode 100644 index 0000000..23b7f85 --- /dev/null +++ b/common-framework/src/main/java/com/klaro/acquiring/common/codec/FieldSpec.java @@ -0,0 +1,28 @@ +package com.klaro.acquiring.common.codec; + +/** + * 전문 단일 필드 정의 (FML/UBF 필드 테이블의 한 행에 대응). + * + * @param name 필드명 + * @param length 고정 길이(byte) + * @param type 필드 유형 + */ +public record FieldSpec(String name, int length, FieldType type) { + + public FieldSpec { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("필드명은 필수입니다"); + } + if (length <= 0) { + throw new IllegalArgumentException("필드 길이는 1 이상이어야 합니다: " + name); + } + } + + public static FieldSpec an(String name, int length) { + return new FieldSpec(name, length, FieldType.ALPHANUMERIC); + } + + public static FieldSpec num(String name, int length) { + return new FieldSpec(name, length, FieldType.NUMERIC); + } +} diff --git a/common-framework/src/main/java/com/klaro/acquiring/common/codec/FieldType.java b/common-framework/src/main/java/com/klaro/acquiring/common/codec/FieldType.java new file mode 100644 index 0000000..5219d50 --- /dev/null +++ b/common-framework/src/main/java/com/klaro/acquiring/common/codec/FieldType.java @@ -0,0 +1,14 @@ +package com.klaro.acquiring.common.codec; + +/** + * 전문 필드 유형. 고정길이 인코딩 규칙을 결정한다. + * + *
    + *
  • {@link #ALPHANUMERIC} : 좌측정렬, 우측 공백 패딩 (문자열)
  • + *
  • {@link #NUMERIC} : 우측정렬, 좌측 0 패딩 (금액/숫자)
  • + *
+ */ +public enum FieldType { + ALPHANUMERIC, + NUMERIC +} diff --git a/common-framework/src/main/java/com/klaro/acquiring/common/codec/MessageCodec.java b/common-framework/src/main/java/com/klaro/acquiring/common/codec/MessageCodec.java new file mode 100644 index 0000000..71298a1 --- /dev/null +++ b/common-framework/src/main/java/com/klaro/acquiring/common/codec/MessageCodec.java @@ -0,0 +1,99 @@ +package com.klaro.acquiring.common.codec; + +import com.klaro.acquiring.common.error.ErrorCode; +import com.klaro.acquiring.common.error.AcquiringException; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 고정길이 전문 코덱. 레거시 TxCore 의 TXBUF(FML/UBF) + 고정길이 전문 송수신을 + * 대체한다. 필드값 Map 과 고정길이 문자열 사이를 왕복(round-trip) 변환한다. + * + *

인코딩 규칙: + *

    + *
  • ALPHANUMERIC : 좌측정렬 후 우측 공백 패딩, 초과 시 잘림
  • + *
  • NUMERIC : 우측정렬 후 좌측 0 패딩, 초과 시 하위자리 유지(왼쪽 잘림)
  • + *
+ * 디코딩 시 ALPHANUMERIC 은 우측 공백 제거, NUMERIC 은 선행 0 제거(빈값은 "0"). + */ +public class MessageCodec { + + private final MessageSpec spec; + + public MessageCodec(MessageSpec spec) { + this.spec = spec; + } + + public MessageSpec spec() { + return spec; + } + + /** 필드값 Map → 고정길이 전문 문자열. */ + public String encode(Map values) { + StringBuilder sb = new StringBuilder(spec.totalLength()); + for (FieldSpec f : spec.fields()) { + String raw = values.getOrDefault(f.name(), ""); + if (raw == null) { + raw = ""; + } + sb.append(pad(raw, f)); + } + return sb.toString(); + } + + /** 고정길이 전문 문자열 → 필드값 Map (선언 순서 유지). */ + public Map decode(String message) { + if (message == null) { + throw new AcquiringException(ErrorCode.INVALID_REQUEST, "전문이 null 입니다"); + } + int expected = spec.totalLength(); + if (message.length() < expected) { + throw new AcquiringException(ErrorCode.INVALID_REQUEST, + "전문 길이 부족: 기대=" + expected + " 실제=" + message.length() + + " [" + spec.name() + "]"); + } + Map out = new LinkedHashMap<>(); + int pos = 0; + for (FieldSpec f : spec.fields()) { + String chunk = message.substring(pos, pos + f.length()); + pos += f.length(); + out.put(f.name(), unpad(chunk, f.type())); + } + return out; + } + + private String pad(String raw, FieldSpec f) { + int len = f.length(); + if (f.type() == FieldType.NUMERIC) { + String digits = raw.isBlank() ? "0" : raw.trim(); + if (digits.length() > len) { + digits = digits.substring(digits.length() - len); + } + return "0".repeat(len - digits.length()) + digits; + } + String v = raw; + if (v.length() > len) { + return v.substring(0, len); + } + return v + " ".repeat(len - v.length()); + } + + private String unpad(String chunk, FieldType type) { + if (type == FieldType.NUMERIC) { + String s = chunk.trim(); + int i = 0; + while (i < s.length() - 1 && s.charAt(i) == '0') { + i++; + } + String stripped = s.isEmpty() ? "0" : s.substring(i); + return stripped.isEmpty() ? "0" : stripped; + } + // 우측 공백 제거만 수행 (선행 공백은 유의미할 수 있어 보존) + int end = chunk.length(); + while (end > 0 && chunk.charAt(end - 1) == ' ') { + end--; + } + return chunk.substring(0, end); + } +} diff --git a/common-framework/src/main/java/com/klaro/acquiring/common/codec/MessageSpec.java b/common-framework/src/main/java/com/klaro/acquiring/common/codec/MessageSpec.java new file mode 100644 index 0000000..86e0e60 --- /dev/null +++ b/common-framework/src/main/java/com/klaro/acquiring/common/codec/MessageSpec.java @@ -0,0 +1,25 @@ +package com.klaro.acquiring.common.codec; + +import java.util.List; + +/** + * 전문 레이아웃(고정길이 필드의 순서 있는 목록). FML 필드 테이블 전체에 대응. + */ +public record MessageSpec(String name, List fields) { + + public MessageSpec { + if (fields == null || fields.isEmpty()) { + throw new IllegalArgumentException("전문 레이아웃에는 최소 1개 필드가 필요합니다"); + } + fields = List.copyOf(fields); + } + + /** 전문 총 길이(byte). */ + public int totalLength() { + return fields.stream().mapToInt(FieldSpec::length).sum(); + } + + public static MessageSpec of(String name, FieldSpec... fields) { + return new MessageSpec(name, List.of(fields)); + } +} diff --git a/common-framework/src/main/java/com/klaro/acquiring/common/dto/ApiResponse.java b/common-framework/src/main/java/com/klaro/acquiring/common/dto/ApiResponse.java new file mode 100644 index 0000000..442ae8a --- /dev/null +++ b/common-framework/src/main/java/com/klaro/acquiring/common/dto/ApiResponse.java @@ -0,0 +1,17 @@ +package com.klaro.acquiring.common.dto; + +import com.klaro.acquiring.common.error.ErrorCode; + +/** + * 표준 응답 봉투. 레거시 응답 TXBUF 의 RESPCODE/데이터 구성을 대체. + */ +public record ApiResponse(String code, String message, T data) { + + public static ApiResponse ok(T data) { + return new ApiResponse<>(ErrorCode.OK.code(), ErrorCode.OK.defaultMessage(), data); + } + + public static ApiResponse error(ErrorCode code, String message) { + return new ApiResponse<>(code.code(), message, null); + } +} diff --git a/common-framework/src/main/java/com/klaro/acquiring/common/error/AcquiringException.java b/common-framework/src/main/java/com/klaro/acquiring/common/error/AcquiringException.java new file mode 100644 index 0000000..77c29d8 --- /dev/null +++ b/common-framework/src/main/java/com/klaro/acquiring/common/error/AcquiringException.java @@ -0,0 +1,23 @@ +package com.klaro.acquiring.common.error; + +/** + * 도메인 예외 최상위. 모든 업무 예외는 ErrorCode 를 동반한다. + */ +public class AcquiringException extends RuntimeException { + + private final ErrorCode errorCode; + + public AcquiringException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public AcquiringException(ErrorCode errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/common-framework/src/main/java/com/klaro/acquiring/common/error/ErrorCode.java b/common-framework/src/main/java/com/klaro/acquiring/common/error/ErrorCode.java new file mode 100644 index 0000000..9fd52b5 --- /dev/null +++ b/common-framework/src/main/java/com/klaro/acquiring/common/error/ErrorCode.java @@ -0,0 +1,33 @@ +package com.klaro.acquiring.common.error; + +/** + * 표준 오류코드. TxCore 의 TX_OK/TX_FAIL/TX_ENOENT/TX_EINVAL/TX_EDB 계열을 대체한다. + */ +public enum ErrorCode { + OK("0000", "정상"), + INVALID_REQUEST("E001", "요청값 오류"), + NOT_FOUND("E002", "대상 없음"), + DUPLICATE("E003", "중복"), + LIMIT_EXCEEDED("E004", "한도 초과"), + RECONCILE_MISMATCH("E005", "대사 불일치"), + BALANCE_MISMATCH("E006", "잔액 불일치"), + PERSISTENCE_ERROR("E007", "저장소 오류"), + ILLEGAL_STATE("E008", "상태 오류"), + INTERNAL_ERROR("E999", "내부 오류"); + + private final String code; + private final String message; + + ErrorCode(String code, String message) { + this.code = code; + this.message = message; + } + + public String code() { + return code; + } + + public String defaultMessage() { + return message; + } +} diff --git a/common-framework/src/main/java/com/klaro/acquiring/common/error/NotFoundException.java b/common-framework/src/main/java/com/klaro/acquiring/common/error/NotFoundException.java new file mode 100644 index 0000000..8a9369f --- /dev/null +++ b/common-framework/src/main/java/com/klaro/acquiring/common/error/NotFoundException.java @@ -0,0 +1,11 @@ +package com.klaro.acquiring.common.error; + +/** + * 대상 미존재. TxCore TX_ENOENT 대응. + */ +public class NotFoundException extends AcquiringException { + + public NotFoundException(String message) { + super(ErrorCode.NOT_FOUND, message); + } +} diff --git a/common-framework/src/main/java/com/klaro/acquiring/common/error/ValidationException.java b/common-framework/src/main/java/com/klaro/acquiring/common/error/ValidationException.java new file mode 100644 index 0000000..646787f --- /dev/null +++ b/common-framework/src/main/java/com/klaro/acquiring/common/error/ValidationException.java @@ -0,0 +1,11 @@ +package com.klaro.acquiring.common.error; + +/** + * 요청 검증 실패. TxCore TX_EINVAL 대응. + */ +public class ValidationException extends AcquiringException { + + public ValidationException(String message) { + super(ErrorCode.INVALID_REQUEST, message); + } +} diff --git a/common-framework/src/main/java/com/klaro/acquiring/common/tx/TxContext.java b/common-framework/src/main/java/com/klaro/acquiring/common/tx/TxContext.java new file mode 100644 index 0000000..3f286fb --- /dev/null +++ b/common-framework/src/main/java/com/klaro/acquiring/common/tx/TxContext.java @@ -0,0 +1,55 @@ +package com.klaro.acquiring.common.tx; + +import java.time.LocalDate; +import java.util.concurrent.atomic.AtomicLong; + +/** + * TxCore TXSVCINFO 의 컨텍스트 요소(xid, 서비스명, 영업일)를 대체하는 경량 컨텍스트. + * 스레드로컬로 현재 처리 컨텍스트를 전달한다. + */ +public final class TxContext { + + private static final AtomicLong XID_SEQ = new AtomicLong(0); + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private final long xid; + private final String service; + private final LocalDate bizDate; + + private TxContext(long xid, String service, LocalDate bizDate) { + this.xid = xid; + this.service = service; + this.bizDate = bizDate; + } + + public static TxContext begin(String service, LocalDate bizDate) { + TxContext ctx = new TxContext(XID_SEQ.incrementAndGet(), service, bizDate); + CURRENT.set(ctx); + return ctx; + } + + public static TxContext current() { + return CURRENT.get(); + } + + public static void clear() { + CURRENT.remove(); + } + + public long xid() { + return xid; + } + + public String service() { + return service; + } + + public LocalDate bizDate() { + return bizDate; + } + + @Override + public String toString() { + return "TxContext{xid=" + xid + ", service='" + service + "', bizDate=" + bizDate + '}'; + } +} diff --git a/common-framework/src/main/java/com/klaro/acquiring/common/tx/TxTemplate.java b/common-framework/src/main/java/com/klaro/acquiring/common/tx/TxTemplate.java new file mode 100644 index 0000000..e9d3256 --- /dev/null +++ b/common-framework/src/main/java/com/klaro/acquiring/common/tx/TxTemplate.java @@ -0,0 +1,31 @@ +package com.klaro.acquiring.common.tx; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.function.Supplier; + +/** + * TxCore 의 tx_begin/tx_commit/tx_abort 관용구를 대체하는 프로그래밍 트랜잭션 헬퍼. + * 선언적 {@code @Transactional} 로 표현하기 어려운 배치 스텝 등에서 사용한다. + */ +@Component +public class TxTemplate { + + private final TransactionTemplate transactionTemplate; + + public TxTemplate(PlatformTransactionManager transactionManager) { + this.transactionTemplate = new TransactionTemplate(transactionManager); + } + + /** 결과를 반환하는 트랜잭션 실행 (예외 시 롤백 = tx_abort). */ + public T execute(Supplier work) { + return transactionTemplate.execute(status -> work.get()); + } + + /** 반환값 없는 트랜잭션 실행. */ + public void run(Runnable work) { + transactionTemplate.executeWithoutResult(status -> work.run()); + } +} diff --git a/common-framework/src/main/java/com/klaro/acquiring/common/util/AmountUtil.java b/common-framework/src/main/java/com/klaro/acquiring/common/util/AmountUtil.java new file mode 100644 index 0000000..c53d4f4 --- /dev/null +++ b/common-framework/src/main/java/com/klaro/acquiring/common/util/AmountUtil.java @@ -0,0 +1,43 @@ +package com.klaro.acquiring.common.util; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * 금액(원) 유틸. 모든 금전 계산은 BigDecimal 로 수행하고 원 단위로 반올림한다 + * (레거시는 long 원화 정수; 수수료 계산 시 절사/반올림 규칙을 명시화). + */ +public final class AmountUtil { + + /** 원 단위 (소수점 0자리). */ + public static final int WON_SCALE = 0; + + private AmountUtil() { + } + + public static BigDecimal won(long amount) { + return BigDecimal.valueOf(amount); + } + + /** 요율 적용 후 원 단위 반올림(HALF_UP). 예: 금액 10000 * 2.3% = 230. */ + public static BigDecimal applyRate(BigDecimal amount, BigDecimal rate) { + if (amount == null || rate == null) { + throw new IllegalArgumentException("금액/요율은 null 일 수 없습니다"); + } + return amount.multiply(rate).setScale(WON_SCALE, RoundingMode.HALF_UP); + } + + /** 원 단위 정규화. */ + public static BigDecimal normalize(BigDecimal amount) { + return amount == null ? BigDecimal.ZERO : amount.setScale(WON_SCALE, RoundingMode.HALF_UP); + } + + public static BigDecimal nz(BigDecimal amount) { + return amount == null ? BigDecimal.ZERO : amount; + } + + /** |a - b| <= tolerance 이면 true (대사 허용오차 비교). */ + public static boolean withinTolerance(BigDecimal a, BigDecimal b, BigDecimal tolerance) { + return nz(a).subtract(nz(b)).abs().compareTo(nz(tolerance)) <= 0; + } +} diff --git a/common-framework/src/main/java/com/klaro/acquiring/common/util/DateUtil.java b/common-framework/src/main/java/com/klaro/acquiring/common/util/DateUtil.java new file mode 100644 index 0000000..bb81f84 --- /dev/null +++ b/common-framework/src/main/java/com/klaro/acquiring/common/util/DateUtil.java @@ -0,0 +1,76 @@ +package com.klaro.acquiring.common.util; + +import com.klaro.acquiring.common.error.ValidationException; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * 영업일(YYYYMMDD) 유틸. 레거시 acq_util 의 date_is_valid / 영업일 계산 대응. + * 주말(토/일)은 비영업일로 간주한다. + */ +public final class DateUtil { + + public static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private DateUtil() { + } + + public static boolean isValid(String yyyymmdd) { + if (yyyymmdd == null || yyyymmdd.length() != 8) { + return false; + } + try { + LocalDate.parse(yyyymmdd, YYYYMMDD); + return true; + } catch (DateTimeParseException e) { + return false; + } + } + + public static LocalDate parse(String yyyymmdd) { + if (!isValid(yyyymmdd)) { + throw new ValidationException("유효하지 않은 영업일: " + yyyymmdd); + } + return LocalDate.parse(yyyymmdd, YYYYMMDD); + } + + public static String format(LocalDate date) { + return date.format(YYYYMMDD); + } + + public static boolean isBusinessDay(LocalDate date) { + DayOfWeek dow = date.getDayOfWeek(); + return dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY; + } + + /** 해당일 이후 첫 영업일 (해당일이 영업일이면 그대로). */ + public static LocalDate businessDayOnOrAfter(LocalDate date) { + LocalDate d = date; + while (!isBusinessDay(d)) { + d = d.plusDays(1); + } + return d; + } + + /** 다음 영업일. */ + public static LocalDate nextBusinessDay(LocalDate date) { + LocalDate d = date.plusDays(1); + return businessDayOnOrAfter(d); + } + + /** n 영업일 후의 지급예정일 (정산주기 계산에 사용). */ + public static LocalDate addBusinessDays(LocalDate date, int n) { + LocalDate d = date; + int added = 0; + while (added < n) { + d = d.plusDays(1); + if (isBusinessDay(d)) { + added++; + } + } + return d; + } +} diff --git a/common-framework/src/test/java/com/klaro/acquiring/common/codec/MessageCodecTest.java b/common-framework/src/test/java/com/klaro/acquiring/common/codec/MessageCodecTest.java new file mode 100644 index 0000000..cec2527 --- /dev/null +++ b/common-framework/src/test/java/com/klaro/acquiring/common/codec/MessageCodecTest.java @@ -0,0 +1,68 @@ +package com.klaro.acquiring.common.codec; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MessageCodecTest { + + private final MessageSpec spec = MessageSpec.of("AC_REQ", + FieldSpec.an("KEY", 10), + FieldSpec.an("MERCHID", 8), + FieldSpec.num("AMOUNT", 12), + FieldSpec.an("BIZDATE", 8), + FieldSpec.an("STATUS", 1)); + + private final MessageCodec codec = new MessageCodec(spec); + + @Test + void encodesFixedLengthWithPaddingRules() { + Map v = new LinkedHashMap<>(); + v.put("KEY", "A123"); + v.put("MERCHID", "M0001"); + v.put("AMOUNT", "10000"); + v.put("BIZDATE", "20260717"); + v.put("STATUS", "R"); + + String msg = codec.encode(v); + + assertThat(msg).hasSize(spec.totalLength()); + assertThat(msg).isEqualTo("A123 " + "M0001 " + "000000010000" + "20260717" + "R"); + } + + @Test + void decodeIsInverseOfEncode() { + Map v = new LinkedHashMap<>(); + v.put("KEY", "A123"); + v.put("MERCHID", "M0001"); + v.put("AMOUNT", "10000"); + v.put("BIZDATE", "20260717"); + v.put("STATUS", "R"); + + Map decoded = codec.decode(codec.encode(v)); + + assertThat(decoded.get("KEY")).isEqualTo("A123"); + assertThat(decoded.get("MERCHID")).isEqualTo("M0001"); + assertThat(decoded.get("AMOUNT")).isEqualTo("10000"); + assertThat(decoded.get("BIZDATE")).isEqualTo("20260717"); + assertThat(decoded.get("STATUS")).isEqualTo("R"); + } + + @Test + void numericFieldStripsLeadingZerosOnDecode() { + Map decoded = codec.decode(codec.encode(Map.of("AMOUNT", "0"))); + assertThat(decoded.get("AMOUNT")).isEqualTo("0"); + assertThat(decoded.get("KEY")).isEmpty(); + } + + @Test + void rejectsTooShortMessage() { + assertThatThrownBy(() -> codec.decode("short")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("전문 길이 부족"); + } +} diff --git a/common-framework/src/test/java/com/klaro/acquiring/common/util/DateAmountUtilTest.java b/common-framework/src/test/java/com/klaro/acquiring/common/util/DateAmountUtilTest.java new file mode 100644 index 0000000..7e07afd --- /dev/null +++ b/common-framework/src/test/java/com/klaro/acquiring/common/util/DateAmountUtilTest.java @@ -0,0 +1,49 @@ +package com.klaro.acquiring.common.util; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +class DateAmountUtilTest { + + @Test + void validatesYyyymmdd() { + assertThat(DateUtil.isValid("20260717")).isTrue(); + assertThat(DateUtil.isValid("20261317")).isFalse(); + assertThat(DateUtil.isValid("2026717")).isFalse(); + assertThat(DateUtil.isValid(null)).isFalse(); + } + + @Test + void skipsWeekendForBusinessDay() { + // 2026-07-17 is a Friday + LocalDate friday = LocalDate.of(2026, 7, 17); + assertThat(DateUtil.isBusinessDay(friday)).isTrue(); + // next business day skips Sat/Sun -> Monday 2026-07-20 + assertThat(DateUtil.nextBusinessDay(friday)).isEqualTo(LocalDate.of(2026, 7, 20)); + } + + @Test + void addBusinessDaysSkipsWeekends() { + LocalDate friday = LocalDate.of(2026, 7, 17); + // +2 business days -> Tuesday 2026-07-21 + assertThat(DateUtil.addBusinessDays(friday, 2)).isEqualTo(LocalDate.of(2026, 7, 21)); + } + + @Test + void appliesRateWithWonRounding() { + BigDecimal fee = AmountUtil.applyRate(AmountUtil.won(10000), new BigDecimal("0.023")); + assertThat(fee).isEqualByComparingTo("230"); + } + + @Test + void toleranceComparison() { + assertThat(AmountUtil.withinTolerance(new BigDecimal("1000"), new BigDecimal("1005"), + new BigDecimal("10"))).isTrue(); + assertThat(AmountUtil.withinTolerance(new BigDecimal("1000"), new BigDecimal("1050"), + new BigDecimal("10"))).isFalse(); + } +} diff --git a/domain/pom.xml b/domain/pom.xml new file mode 100644 index 0000000..e1242c6 --- /dev/null +++ b/domain/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + com.klaro.acquiring + card-acquiring-boot + 1.0.0 + + domain + domain + 카드 매입·정산 도메인 엔티티/DTO/enum + + + + com.klaro.acquiring + common-framework + + + jakarta.persistence + jakarta.persistence-api + + + jakarta.validation + jakarta.validation-api + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/domain/src/main/java/com/klaro/acquiring/domain/dto/FeeBreakdown.java b/domain/src/main/java/com/klaro/acquiring/domain/dto/FeeBreakdown.java new file mode 100644 index 0000000..0b08877 --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/dto/FeeBreakdown.java @@ -0,0 +1,16 @@ +package com.klaro.acquiring.domain.dto; + +import java.math.BigDecimal; + +/** + * 수수료 계산 결과(분해). 매입 금액에 대해 MDR/VAN 수수료와 순지급액(net)을 담는다. + * + * @param baseAmount 기준(매입) 금액 + * @param mdrFee 가맹점 할인수수료 + * @param vanFee VAN 수수료 + * @param totalFee 총 수수료 (mdr + van) + * @param netAmount 순지급액 (base - totalFee) + */ +public record FeeBreakdown(BigDecimal baseAmount, BigDecimal mdrFee, BigDecimal vanFee, + BigDecimal totalFee, BigDecimal netAmount) { +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/entity/Approval.java b/domain/src/main/java/com/klaro/acquiring/domain/entity/Approval.java new file mode 100644 index 0000000..55b820f --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/entity/Approval.java @@ -0,0 +1,69 @@ +package com.klaro.acquiring.domain.entity; + +import com.klaro.acquiring.domain.enums.TxStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** 승인 원장(au). 매입/대사의 기준이 되는 승인 거래. */ +@Entity +@Table(name = "approval") +public class Approval { + + @Id + @Column(name = "appr_no", length = 12) + private String apprNo; + + @Column(name = "merch_id", length = 15, nullable = false) + private String merchId; + + @Column(name = "card_no", length = 19, nullable = false) + private String cardNo; + + @Column(name = "amount", precision = 18, scale = 2, nullable = false) + private BigDecimal amount; + + @Column(name = "appr_date", length = 8, nullable = false) + private String apprDate; + + @Column(name = "biz_date", nullable = false) + private LocalDate bizDate; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 12, nullable = false) + private TxStatus status = TxStatus.RECEIVED; + + protected Approval() { + } + + public Approval(String apprNo, String merchId, String cardNo, BigDecimal amount, + String apprDate, LocalDate bizDate) { + this.apprNo = apprNo; + this.merchId = merchId; + this.cardNo = cardNo; + this.amount = amount; + this.apprDate = apprDate; + this.bizDate = bizDate; + } + + public String getApprNo() { return apprNo; } + public void setApprNo(String apprNo) { this.apprNo = apprNo; } + public String getMerchId() { return merchId; } + public void setMerchId(String merchId) { this.merchId = merchId; } + public String getCardNo() { return cardNo; } + public void setCardNo(String cardNo) { this.cardNo = cardNo; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public String getApprDate() { return apprDate; } + public void setApprDate(String apprDate) { this.apprDate = apprDate; } + public LocalDate getBizDate() { return bizDate; } + public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; } + public TxStatus getStatus() { return status; } + public void setStatus(TxStatus status) { this.status = status; } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/entity/Card.java b/domain/src/main/java/com/klaro/acquiring/domain/entity/Card.java new file mode 100644 index 0000000..ad55acb --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/entity/Card.java @@ -0,0 +1,49 @@ +package com.klaro.acquiring.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** 카드 마스터(마스킹 카드번호 기준). */ +@Entity +@Table(name = "card") +public class Card { + + @Id + @Column(name = "card_no", length = 19) + private String cardNo; + + @Column(name = "bin", length = 6, nullable = false) + private String bin; + + @Column(name = "brand", length = 10, nullable = false) + private String brand; + + @Column(name = "issuer_code", length = 4) + private String issuerCode; + + @Column(name = "status", length = 1, nullable = false) + private String status = "A"; + + protected Card() { + } + + public Card(String cardNo, String bin, String brand, String issuerCode) { + this.cardNo = cardNo; + this.bin = bin; + this.brand = brand; + this.issuerCode = issuerCode; + } + + public String getCardNo() { return cardNo; } + public void setCardNo(String cardNo) { this.cardNo = cardNo; } + public String getBin() { return bin; } + public void setBin(String bin) { this.bin = bin; } + public String getBrand() { return brand; } + public void setBrand(String brand) { this.brand = brand; } + public String getIssuerCode() { return issuerCode; } + public void setIssuerCode(String issuerCode) { this.issuerCode = issuerCode; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/entity/Deposit.java b/domain/src/main/java/com/klaro/acquiring/domain/entity/Deposit.java new file mode 100644 index 0000000..3fadad0 --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/entity/Deposit.java @@ -0,0 +1,55 @@ +package com.klaro.acquiring.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** 매입사 입금 통보(대사 3-leg 중 입금 leg). 승인번호 기준 매칭. */ +@Entity +@Table(name = "deposit") +public class Deposit { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "deposit_id") + private Long depositId; + + @Column(name = "appr_no", length = 12, nullable = false) + private String apprNo; + + @Column(name = "merch_id", length = 15, nullable = false) + private String merchId; + + @Column(name = "amount", precision = 18, scale = 2, nullable = false) + private BigDecimal amount; + + @Column(name = "biz_date", nullable = false) + private LocalDate bizDate; + + protected Deposit() { + } + + public Deposit(String apprNo, String merchId, BigDecimal amount, LocalDate bizDate) { + this.apprNo = apprNo; + this.merchId = merchId; + this.amount = amount; + this.bizDate = bizDate; + } + + public Long getDepositId() { return depositId; } + public void setDepositId(Long depositId) { this.depositId = depositId; } + public String getApprNo() { return apprNo; } + public void setApprNo(String apprNo) { this.apprNo = apprNo; } + public String getMerchId() { return merchId; } + public void setMerchId(String merchId) { this.merchId = merchId; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public LocalDate getBizDate() { return bizDate; } + public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/entity/Fee.java b/domain/src/main/java/com/klaro/acquiring/domain/entity/Fee.java new file mode 100644 index 0000000..ec96144 --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/entity/Fee.java @@ -0,0 +1,78 @@ +package com.klaro.acquiring.domain.entity; + +import com.klaro.acquiring.domain.enums.FeeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** 매입 건별 수수료 내역(분해). MDR/VAN 각각 1행. */ +@Entity +@Table(name = "fee") +public class Fee { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "fee_id") + private Long feeId; + + @Column(name = "purchase_id", nullable = false) + private Long purchaseId; + + @Column(name = "merch_id", length = 15, nullable = false) + private String merchId; + + @Enumerated(EnumType.STRING) + @Column(name = "fee_type", length = 8, nullable = false) + private FeeType feeType; + + @Column(name = "base_amount", precision = 18, scale = 2, nullable = false) + private BigDecimal baseAmount; + + @Column(name = "rate", precision = 6, scale = 4, nullable = false) + private BigDecimal rate = BigDecimal.ZERO; + + @Column(name = "fee_amount", precision = 18, scale = 2, nullable = false) + private BigDecimal feeAmount; + + @Column(name = "biz_date", nullable = false) + private LocalDate bizDate; + + protected Fee() { + } + + public Fee(Long purchaseId, String merchId, FeeType feeType, BigDecimal baseAmount, + BigDecimal rate, BigDecimal feeAmount, LocalDate bizDate) { + this.purchaseId = purchaseId; + this.merchId = merchId; + this.feeType = feeType; + this.baseAmount = baseAmount; + this.rate = rate; + this.feeAmount = feeAmount; + this.bizDate = bizDate; + } + + public Long getFeeId() { return feeId; } + public void setFeeId(Long feeId) { this.feeId = feeId; } + public Long getPurchaseId() { return purchaseId; } + public void setPurchaseId(Long purchaseId) { this.purchaseId = purchaseId; } + public String getMerchId() { return merchId; } + public void setMerchId(String merchId) { this.merchId = merchId; } + public FeeType getFeeType() { return feeType; } + public void setFeeType(FeeType feeType) { this.feeType = feeType; } + public BigDecimal getBaseAmount() { return baseAmount; } + public void setBaseAmount(BigDecimal baseAmount) { this.baseAmount = baseAmount; } + public BigDecimal getRate() { return rate; } + public void setRate(BigDecimal rate) { this.rate = rate; } + public BigDecimal getFeeAmount() { return feeAmount; } + public void setFeeAmount(BigDecimal feeAmount) { this.feeAmount = feeAmount; } + public LocalDate getBizDate() { return bizDate; } + public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/entity/LedgerEntry.java b/domain/src/main/java/com/klaro/acquiring/domain/entity/LedgerEntry.java new file mode 100644 index 0000000..e37972e --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/entity/LedgerEntry.java @@ -0,0 +1,80 @@ +package com.klaro.acquiring.domain.entity; + +import com.klaro.acquiring.domain.enums.DrCr; +import com.klaro.acquiring.domain.enums.EntryType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** 원장 전표(lg). 매입/수수료/정산/지급 반영 및 잔액검증 기준. */ +@Entity +@Table(name = "ledger_entry") +public class LedgerEntry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "entry_id") + private Long entryId; + + @Column(name = "merch_id", length = 15, nullable = false) + private String merchId; + + @Column(name = "biz_date", nullable = false) + private LocalDate bizDate; + + @Enumerated(EnumType.STRING) + @Column(name = "entry_type", length = 12, nullable = false) + private EntryType entryType; + + @Enumerated(EnumType.STRING) + @Column(name = "dr_cr", length = 8, nullable = false) + private DrCr drCr; + + @Column(name = "amount", precision = 18, scale = 2, nullable = false) + private BigDecimal amount; + + @Column(name = "ref_type", length = 20) + private String refType; + + @Column(name = "ref_id", length = 30) + private String refId; + + protected LedgerEntry() { + } + + public LedgerEntry(String merchId, LocalDate bizDate, EntryType entryType, DrCr drCr, + BigDecimal amount, String refType, String refId) { + this.merchId = merchId; + this.bizDate = bizDate; + this.entryType = entryType; + this.drCr = drCr; + this.amount = amount; + this.refType = refType; + this.refId = refId; + } + + public Long getEntryId() { return entryId; } + public void setEntryId(Long entryId) { this.entryId = entryId; } + public String getMerchId() { return merchId; } + public void setMerchId(String merchId) { this.merchId = merchId; } + public LocalDate getBizDate() { return bizDate; } + public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; } + public EntryType getEntryType() { return entryType; } + public void setEntryType(EntryType entryType) { this.entryType = entryType; } + public DrCr getDrCr() { return drCr; } + public void setDrCr(DrCr drCr) { this.drCr = drCr; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public String getRefType() { return refType; } + public void setRefType(String refType) { this.refType = refType; } + public String getRefId() { return refId; } + public void setRefId(String refId) { this.refId = refId; } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/entity/Merchant.java b/domain/src/main/java/com/klaro/acquiring/domain/entity/Merchant.java new file mode 100644 index 0000000..2a77c4b --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/entity/Merchant.java @@ -0,0 +1,141 @@ +package com.klaro.acquiring.domain.entity; + +import com.klaro.acquiring.domain.enums.MerchantStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.math.BigDecimal; + +/** + * 가맹점 마스터. 레거시 merchant 테이블 + 수수료율/정산주기/지급계좌 마스터를 통합. + */ +@Entity +@Table(name = "merchant") +public class Merchant { + + @Id + @Column(name = "merch_id", length = 15) + private String merchId; + + @Column(name = "merch_name", length = 60, nullable = false) + private String merchName; + + @Column(name = "biz_no", length = 12) + private String bizNo; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 12, nullable = false) + private MerchantStatus status = MerchantStatus.ACTIVE; + + /** 가맹점 할인수수료율 (MDR), 예: 0.0230 = 2.3% */ + @Column(name = "mdr_rate", precision = 6, scale = 4, nullable = false) + private BigDecimal mdrRate = BigDecimal.ZERO; + + /** VAN 건당 정액 수수료(원) */ + @Column(name = "van_fee", precision = 18, scale = 2, nullable = false) + private BigDecimal vanFee = BigDecimal.ZERO; + + /** 정산주기 (T+n 영업일) */ + @Column(name = "settlement_cycle", nullable = false) + private int settlementCycle = 2; + + @Column(name = "bank_code", length = 3) + private String bankCode; + + @Column(name = "account_no", length = 20) + private String accountNo; + + protected Merchant() { + } + + public Merchant(String merchId, String merchName, BigDecimal mdrRate, BigDecimal vanFee, + int settlementCycle, String bankCode, String accountNo) { + this.merchId = merchId; + this.merchName = merchName; + this.mdrRate = mdrRate; + this.vanFee = vanFee; + this.settlementCycle = settlementCycle; + this.bankCode = bankCode; + this.accountNo = accountNo; + } + + public String getMerchId() { + return merchId; + } + + public void setMerchId(String merchId) { + this.merchId = merchId; + } + + public String getMerchName() { + return merchName; + } + + public void setMerchName(String merchName) { + this.merchName = merchName; + } + + public String getBizNo() { + return bizNo; + } + + public void setBizNo(String bizNo) { + this.bizNo = bizNo; + } + + public MerchantStatus getStatus() { + return status; + } + + public void setStatus(MerchantStatus status) { + this.status = status; + } + + public BigDecimal getMdrRate() { + return mdrRate; + } + + public void setMdrRate(BigDecimal mdrRate) { + this.mdrRate = mdrRate; + } + + public BigDecimal getVanFee() { + return vanFee; + } + + public void setVanFee(BigDecimal vanFee) { + this.vanFee = vanFee; + } + + public int getSettlementCycle() { + return settlementCycle; + } + + public void setSettlementCycle(int settlementCycle) { + this.settlementCycle = settlementCycle; + } + + public String getBankCode() { + return bankCode; + } + + public void setBankCode(String bankCode) { + this.bankCode = bankCode; + } + + public String getAccountNo() { + return accountNo; + } + + public void setAccountNo(String accountNo) { + this.accountNo = accountNo; + } + + public boolean isActive() { + return status == MerchantStatus.ACTIVE; + } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/entity/Payment.java b/domain/src/main/java/com/klaro/acquiring/domain/entity/Payment.java new file mode 100644 index 0000000..e873905 --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/entity/Payment.java @@ -0,0 +1,77 @@ +package com.klaro.acquiring.domain.entity; + +import com.klaro.acquiring.domain.enums.PaymentStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** 지급(py). 정산 결과에 대한 가맹점 계좌 지급. */ +@Entity +@Table(name = "payment") +public class Payment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "payment_id") + private Long paymentId; + + @Column(name = "settlement_id", nullable = false) + private Long settlementId; + + @Column(name = "merch_id", length = 15, nullable = false) + private String merchId; + + @Column(name = "pay_date", nullable = false) + private LocalDate payDate; + + @Column(name = "amount", precision = 18, scale = 2, nullable = false) + private BigDecimal amount; + + @Column(name = "bank_code", length = 3) + private String bankCode; + + @Column(name = "account_no", length = 20) + private String accountNo; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 15, nullable = false) + private PaymentStatus status = PaymentStatus.READY; + + protected Payment() { + } + + public Payment(Long settlementId, String merchId, LocalDate payDate, BigDecimal amount, + String bankCode, String accountNo) { + this.settlementId = settlementId; + this.merchId = merchId; + this.payDate = payDate; + this.amount = amount; + this.bankCode = bankCode; + this.accountNo = accountNo; + } + + public Long getPaymentId() { return paymentId; } + public void setPaymentId(Long paymentId) { this.paymentId = paymentId; } + public Long getSettlementId() { return settlementId; } + public void setSettlementId(Long settlementId) { this.settlementId = settlementId; } + public String getMerchId() { return merchId; } + public void setMerchId(String merchId) { this.merchId = merchId; } + public LocalDate getPayDate() { return payDate; } + public void setPayDate(LocalDate payDate) { this.payDate = payDate; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public String getBankCode() { return bankCode; } + public void setBankCode(String bankCode) { this.bankCode = bankCode; } + public String getAccountNo() { return accountNo; } + public void setAccountNo(String accountNo) { this.accountNo = accountNo; } + public PaymentStatus getStatus() { return status; } + public void setStatus(PaymentStatus status) { this.status = status; } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/entity/Purchase.java b/domain/src/main/java/com/klaro/acquiring/domain/entity/Purchase.java new file mode 100644 index 0000000..de90c33 --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/entity/Purchase.java @@ -0,0 +1,89 @@ +package com.klaro.acquiring.domain.entity; + +import com.klaro.acquiring.domain.enums.TxStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** 매입 원장(ac). 승인 건에 대한 매입 접수/청구 결과 + 수수료 분해. */ +@Entity +@Table(name = "purchase") +public class Purchase { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "purchase_id") + private Long purchaseId; + + @Column(name = "appr_no", length = 12, nullable = false, unique = true) + private String apprNo; + + @Column(name = "merch_id", length = 15, nullable = false) + private String merchId; + + @Column(name = "card_no", length = 19, nullable = false) + private String cardNo; + + @Column(name = "amount", precision = 18, scale = 2, nullable = false) + private BigDecimal amount; + + @Column(name = "mdr_fee", precision = 18, scale = 2, nullable = false) + private BigDecimal mdrFee = BigDecimal.ZERO; + + @Column(name = "van_fee", precision = 18, scale = 2, nullable = false) + private BigDecimal vanFee = BigDecimal.ZERO; + + @Column(name = "net_amount", precision = 18, scale = 2, nullable = false) + private BigDecimal netAmount = BigDecimal.ZERO; + + @Column(name = "biz_date", nullable = false) + private LocalDate bizDate; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 12, nullable = false) + private TxStatus status = TxStatus.RECEIVED; + + protected Purchase() { + } + + public Purchase(String apprNo, String merchId, String cardNo, BigDecimal amount, LocalDate bizDate) { + this.apprNo = apprNo; + this.merchId = merchId; + this.cardNo = cardNo; + this.amount = amount; + this.bizDate = bizDate; + } + + public BigDecimal totalFee() { + return mdrFee.add(vanFee); + } + + public Long getPurchaseId() { return purchaseId; } + public void setPurchaseId(Long purchaseId) { this.purchaseId = purchaseId; } + public String getApprNo() { return apprNo; } + public void setApprNo(String apprNo) { this.apprNo = apprNo; } + public String getMerchId() { return merchId; } + public void setMerchId(String merchId) { this.merchId = merchId; } + public String getCardNo() { return cardNo; } + public void setCardNo(String cardNo) { this.cardNo = cardNo; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public BigDecimal getMdrFee() { return mdrFee; } + public void setMdrFee(BigDecimal mdrFee) { this.mdrFee = mdrFee; } + public BigDecimal getVanFee() { return vanFee; } + public void setVanFee(BigDecimal vanFee) { this.vanFee = vanFee; } + public BigDecimal getNetAmount() { return netAmount; } + public void setNetAmount(BigDecimal netAmount) { this.netAmount = netAmount; } + public LocalDate getBizDate() { return bizDate; } + public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; } + public TxStatus getStatus() { return status; } + public void setStatus(TxStatus status) { this.status = status; } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/entity/ReconcileResult.java b/domain/src/main/java/com/klaro/acquiring/domain/entity/ReconcileResult.java new file mode 100644 index 0000000..7a3b85c --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/entity/ReconcileResult.java @@ -0,0 +1,88 @@ +package com.klaro.acquiring.domain.entity; + +import com.klaro.acquiring.domain.enums.TxStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** 대사 결과(rc). 승인-매입-입금 3-way 매칭 결과. */ +@Entity +@Table(name = "reconcile_result") +public class ReconcileResult { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "recon_id") + private Long reconId; + + @Column(name = "biz_date", nullable = false) + private LocalDate bizDate; + + @Column(name = "appr_no", length = 12, nullable = false) + private String apprNo; + + @Column(name = "merch_id", length = 15, nullable = false) + private String merchId; + + @Column(name = "approval_amount", precision = 18, scale = 2, nullable = false) + private BigDecimal approvalAmount = BigDecimal.ZERO; + + @Column(name = "purchase_amount", precision = 18, scale = 2, nullable = false) + private BigDecimal purchaseAmount = BigDecimal.ZERO; + + @Column(name = "deposit_amount", precision = 18, scale = 2, nullable = false) + private BigDecimal depositAmount = BigDecimal.ZERO; + + @Column(name = "diff_amount", precision = 18, scale = 2, nullable = false) + private BigDecimal diffAmount = BigDecimal.ZERO; + + @Column(name = "matched", nullable = false) + private boolean matched; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 12, nullable = false) + private TxStatus status = TxStatus.RECEIVED; + + @Column(name = "reason", length = 100) + private String reason; + + protected ReconcileResult() { + } + + public ReconcileResult(LocalDate bizDate, String apprNo, String merchId) { + this.bizDate = bizDate; + this.apprNo = apprNo; + this.merchId = merchId; + } + + public Long getReconId() { return reconId; } + public void setReconId(Long reconId) { this.reconId = reconId; } + public LocalDate getBizDate() { return bizDate; } + public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; } + public String getApprNo() { return apprNo; } + public void setApprNo(String apprNo) { this.apprNo = apprNo; } + public String getMerchId() { return merchId; } + public void setMerchId(String merchId) { this.merchId = merchId; } + public BigDecimal getApprovalAmount() { return approvalAmount; } + public void setApprovalAmount(BigDecimal approvalAmount) { this.approvalAmount = approvalAmount; } + public BigDecimal getPurchaseAmount() { return purchaseAmount; } + public void setPurchaseAmount(BigDecimal purchaseAmount) { this.purchaseAmount = purchaseAmount; } + public BigDecimal getDepositAmount() { return depositAmount; } + public void setDepositAmount(BigDecimal depositAmount) { this.depositAmount = depositAmount; } + public BigDecimal getDiffAmount() { return diffAmount; } + public void setDiffAmount(BigDecimal diffAmount) { this.diffAmount = diffAmount; } + public boolean isMatched() { return matched; } + public void setMatched(boolean matched) { this.matched = matched; } + public TxStatus getStatus() { return status; } + public void setStatus(TxStatus status) { this.status = status; } + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/entity/Settlement.java b/domain/src/main/java/com/klaro/acquiring/domain/entity/Settlement.java new file mode 100644 index 0000000..634af95 --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/entity/Settlement.java @@ -0,0 +1,81 @@ +package com.klaro.acquiring.domain.entity; + +import com.klaro.acquiring.domain.enums.TxStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** 정산(st). 가맹점·영업일 단위 매입 집계 + netting 결과. */ +@Entity +@Table(name = "settlement", + uniqueConstraints = @UniqueConstraint(name = "uq_settlement_merch_date", + columnNames = {"merch_id", "biz_date"})) +public class Settlement { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "settlement_id") + private Long settlementId; + + @Column(name = "merch_id", length = 15, nullable = false) + private String merchId; + + @Column(name = "biz_date", nullable = false) + private LocalDate bizDate; + + @Column(name = "gross_amount", precision = 18, scale = 2, nullable = false) + private BigDecimal grossAmount = BigDecimal.ZERO; + + @Column(name = "total_fee", precision = 18, scale = 2, nullable = false) + private BigDecimal totalFee = BigDecimal.ZERO; + + @Column(name = "net_amount", precision = 18, scale = 2, nullable = false) + private BigDecimal netAmount = BigDecimal.ZERO; + + @Column(name = "txn_count", nullable = false) + private int txnCount; + + @Column(name = "pay_date", nullable = false) + private LocalDate payDate; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 12, nullable = false) + private TxStatus status = TxStatus.DONE; + + protected Settlement() { + } + + public Settlement(String merchId, LocalDate bizDate, LocalDate payDate) { + this.merchId = merchId; + this.bizDate = bizDate; + this.payDate = payDate; + } + + public Long getSettlementId() { return settlementId; } + public void setSettlementId(Long settlementId) { this.settlementId = settlementId; } + public String getMerchId() { return merchId; } + public void setMerchId(String merchId) { this.merchId = merchId; } + public LocalDate getBizDate() { return bizDate; } + public void setBizDate(LocalDate bizDate) { this.bizDate = bizDate; } + public BigDecimal getGrossAmount() { return grossAmount; } + public void setGrossAmount(BigDecimal grossAmount) { this.grossAmount = grossAmount; } + public BigDecimal getTotalFee() { return totalFee; } + public void setTotalFee(BigDecimal totalFee) { this.totalFee = totalFee; } + public BigDecimal getNetAmount() { return netAmount; } + public void setNetAmount(BigDecimal netAmount) { this.netAmount = netAmount; } + public int getTxnCount() { return txnCount; } + public void setTxnCount(int txnCount) { this.txnCount = txnCount; } + public LocalDate getPayDate() { return payDate; } + public void setPayDate(LocalDate payDate) { this.payDate = payDate; } + public TxStatus getStatus() { return status; } + public void setStatus(TxStatus status) { this.status = status; } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/enums/DrCr.java b/domain/src/main/java/com/klaro/acquiring/domain/enums/DrCr.java new file mode 100644 index 0000000..89a84c3 --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/enums/DrCr.java @@ -0,0 +1,17 @@ +package com.klaro.acquiring.domain.enums; + +/** 원장 차변/대변. */ +public enum DrCr { + DEBIT("차변"), + CREDIT("대변"); + + private final String label; + + DrCr(String label) { + this.label = label; + } + + public String label() { + return label; + } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/enums/EntryType.java b/domain/src/main/java/com/klaro/acquiring/domain/enums/EntryType.java new file mode 100644 index 0000000..511a871 --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/enums/EntryType.java @@ -0,0 +1,19 @@ +package com.klaro.acquiring.domain.enums; + +/** 원장 전표 유형. */ +public enum EntryType { + PURCHASE("매입"), + FEE("수수료"), + SETTLEMENT("정산"), + PAYMENT("지급"); + + private final String label; + + EntryType(String label) { + this.label = label; + } + + public String label() { + return label; + } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/enums/FeeType.java b/domain/src/main/java/com/klaro/acquiring/domain/enums/FeeType.java new file mode 100644 index 0000000..60e50ee --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/enums/FeeType.java @@ -0,0 +1,17 @@ +package com.klaro.acquiring.domain.enums; + +/** 수수료 유형. 매입 건별 수수료 내역 분해에 사용. */ +public enum FeeType { + MDR("가맹점 할인수수료"), + VAN("VAN 수수료"); + + private final String label; + + FeeType(String label) { + this.label = label; + } + + public String label() { + return label; + } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/enums/MerchantStatus.java b/domain/src/main/java/com/klaro/acquiring/domain/enums/MerchantStatus.java new file mode 100644 index 0000000..d17f0d0 --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/enums/MerchantStatus.java @@ -0,0 +1,24 @@ +package com.klaro.acquiring.domain.enums; + +/** 가맹점 상태. 레거시 merchant.status(CHAR(1)) 대응. */ +public enum MerchantStatus { + ACTIVE("A", "정상"), + SUSPENDED("H", "정지"), + CLOSED("C", "해지"); + + private final String code; + private final String label; + + MerchantStatus(String code, String label) { + this.code = code; + this.label = label; + } + + public String code() { + return code; + } + + public String label() { + return label; + } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/enums/PaymentStatus.java b/domain/src/main/java/com/klaro/acquiring/domain/enums/PaymentStatus.java new file mode 100644 index 0000000..c2ba639 --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/enums/PaymentStatus.java @@ -0,0 +1,19 @@ +package com.klaro.acquiring.domain.enums; + +/** 지급 상태. */ +public enum PaymentStatus { + READY("지급대기"), + FILE_CREATED("지급파일생성"), + PAID("지급완료"), + FAILED("지급실패"); + + private final String label; + + PaymentStatus(String label) { + this.label = label; + } + + public String label() { + return label; + } +} diff --git a/domain/src/main/java/com/klaro/acquiring/domain/enums/TxStatus.java b/domain/src/main/java/com/klaro/acquiring/domain/enums/TxStatus.java new file mode 100644 index 0000000..f0820e0 --- /dev/null +++ b/domain/src/main/java/com/klaro/acquiring/domain/enums/TxStatus.java @@ -0,0 +1,36 @@ +package com.klaro.acquiring.domain.enums; + +/** + * 처리 상태코드. 레거시 *_core.h 의 상태 상수(R/M/U/S) 대응. + */ +public enum TxStatus { + RECEIVED("R", "접수/수신"), + DONE("M", "처리완료"), + FAILED("U", "불일치/실패"), + SETTLED("S", "정산완료"); + + private final String code; + private final String label; + + TxStatus(String code, String label) { + this.code = code; + this.label = label; + } + + public String code() { + return code; + } + + public String label() { + return label; + } + + public static TxStatus fromCode(String code) { + for (TxStatus s : values()) { + if (s.code.equals(code)) { + return s; + } + } + throw new IllegalArgumentException("알 수 없는 상태코드: " + code); + } +} diff --git a/modules/acquiring/pom.xml b/modules/acquiring/pom.xml new file mode 100644 index 0000000..1e35149 --- /dev/null +++ b/modules/acquiring/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + com.klaro.acquiring + card-acquiring-boot + 1.0.0 + ../../pom.xml + + acquiring + acquiring + + + + com.klaro.acquiring + common-framework + + + com.klaro.acquiring + domain + + + com.klaro.acquiring + persistence + + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/modules/acquiring/src/main/java/com/klaro/acquiring/acquiring/AcquiringService.java b/modules/acquiring/src/main/java/com/klaro/acquiring/acquiring/AcquiringService.java new file mode 100644 index 0000000..8f84ba5 --- /dev/null +++ b/modules/acquiring/src/main/java/com/klaro/acquiring/acquiring/AcquiringService.java @@ -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)); + } +} diff --git a/modules/acquiring/src/main/java/com/klaro/acquiring/acquiring/FeeCalculator.java b/modules/acquiring/src/main/java/com/klaro/acquiring/acquiring/FeeCalculator.java new file mode 100644 index 0000000..c1dd975 --- /dev/null +++ b/modules/acquiring/src/main/java/com/klaro/acquiring/acquiring/FeeCalculator.java @@ -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 모듈의 수수료 계약을 매입 접수 시점에 적용). + * + *

수수료 = 가맹점 할인수수료(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); + } +} diff --git a/modules/acquiring/src/main/java/com/klaro/acquiring/acquiring/dto/PurchaseCommand.java b/modules/acquiring/src/main/java/com/klaro/acquiring/acquiring/dto/PurchaseCommand.java new file mode 100644 index 0000000..a344583 --- /dev/null +++ b/modules/acquiring/src/main/java/com/klaro/acquiring/acquiring/dto/PurchaseCommand.java @@ -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) { +} diff --git a/modules/acquiring/src/test/java/com/klaro/acquiring/acquiring/FeeCalculatorTest.java b/modules/acquiring/src/test/java/com/klaro/acquiring/acquiring/FeeCalculatorTest.java new file mode 100644 index 0000000..038cbb9 --- /dev/null +++ b/modules/acquiring/src/test/java/com/klaro/acquiring/acquiring/FeeCalculatorTest.java @@ -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"); + } +} diff --git a/modules/closing/pom.xml b/modules/closing/pom.xml new file mode 100644 index 0000000..b8ec729 --- /dev/null +++ b/modules/closing/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + com.klaro.acquiring + card-acquiring-boot + 1.0.0 + ../../pom.xml + + closing + closing + + + + com.klaro.acquiring + common-framework + + + com.klaro.acquiring + domain + + + com.klaro.acquiring + persistence + + + com.klaro.acquiring + settlement + + + com.klaro.acquiring + ledger + + + org.springframework.boot + spring-boot-starter-batch + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/modules/closing/src/main/java/com/klaro/acquiring/closing/ClosingService.java b/modules/closing/src/main/java/com/klaro/acquiring/closing/ClosingService.java new file mode 100644 index 0000000..d641ae4 --- /dev/null +++ b/modules/closing/src/main/java/com/klaro/acquiring/closing/ClosingService.java @@ -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 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 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) { + } +} diff --git a/modules/closing/src/main/java/com/klaro/acquiring/closing/batch/ClosingJobConfig.java b/modules/closing/src/main/java/com/klaro/acquiring/closing/batch/ClosingJobConfig.java new file mode 100644 index 0000000..218c737 --- /dev/null +++ b/modules/closing/src/main/java/com/klaro/acquiring/closing/batch/ClosingJobConfig.java @@ -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(); + } +} diff --git a/modules/gateway/pom.xml b/modules/gateway/pom.xml new file mode 100644 index 0000000..bbd3f23 --- /dev/null +++ b/modules/gateway/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + com.klaro.acquiring + card-acquiring-boot + 1.0.0 + ../../pom.xml + + gateway + gateway + + + + com.klaro.acquiring + common-framework + + + com.klaro.acquiring + domain + + + com.klaro.acquiring + persistence + + + com.klaro.acquiring + acquiring + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/modules/gateway/src/main/java/com/klaro/acquiring/gateway/GatewayService.java b/modules/gateway/src/main/java/com/klaro/acquiring/gateway/GatewayService.java new file mode 100644 index 0000000..7cf772c --- /dev/null +++ b/modules/gateway/src/main/java/com/klaro/acquiring/gateway/GatewayService.java @@ -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 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 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; + } +} diff --git a/modules/ledger/pom.xml b/modules/ledger/pom.xml new file mode 100644 index 0000000..3db84ac --- /dev/null +++ b/modules/ledger/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + com.klaro.acquiring + card-acquiring-boot + 1.0.0 + ../../pom.xml + + ledger + ledger + + + + com.klaro.acquiring + common-framework + + + com.klaro.acquiring + domain + + + com.klaro.acquiring + persistence + + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/modules/ledger/src/main/java/com/klaro/acquiring/ledger/LedgerService.java b/modules/ledger/src/main/java/com/klaro/acquiring/ledger/LedgerService.java new file mode 100644 index 0000000..ca87c9c --- /dev/null +++ b/modules/ledger/src/main/java/com/klaro/acquiring/ledger/LedgerService.java @@ -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) 반영·잔액검증 서비스. + * + *

매입 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 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; + } +} diff --git a/modules/master/pom.xml b/modules/master/pom.xml new file mode 100644 index 0000000..c278c4b --- /dev/null +++ b/modules/master/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + com.klaro.acquiring + card-acquiring-boot + 1.0.0 + ../../pom.xml + + master + master + + + + com.klaro.acquiring + common-framework + + + com.klaro.acquiring + domain + + + com.klaro.acquiring + persistence + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/modules/master/src/main/java/com/klaro/acquiring/master/MasterController.java b/modules/master/src/main/java/com/klaro/acquiring/master/MasterController.java new file mode 100644 index 0000000..cd4a2ba --- /dev/null +++ b/modules/master/src/main/java/com/klaro/acquiring/master/MasterController.java @@ -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 register(@Valid @RequestBody MerchantRequest req) { + return ApiResponse.ok(MerchantResponse.from(masterService.register(req))); + } + + @PutMapping("/{merchId}") + public ApiResponse update(@PathVariable String merchId, + @Valid @RequestBody MerchantRequest req) { + return ApiResponse.ok(MerchantResponse.from(masterService.update(merchId, req))); + } + + @GetMapping("/{merchId}") + public ApiResponse get(@PathVariable String merchId) { + return ApiResponse.ok(MerchantResponse.from(masterService.get(merchId))); + } + + @GetMapping + public ApiResponse> list() { + return ApiResponse.ok(masterService.list().stream().map(MerchantResponse::from).toList()); + } +} diff --git a/modules/master/src/main/java/com/klaro/acquiring/master/MasterService.java b/modules/master/src/main/java/com/klaro/acquiring/master/MasterService.java new file mode 100644 index 0000000..323fdc7 --- /dev/null +++ b/modules/master/src/main/java/com/klaro/acquiring/master/MasterService.java @@ -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 list() { + return merchantRepository.findAll(); + } +} diff --git a/modules/master/src/main/java/com/klaro/acquiring/master/dto/MerchantRequest.java b/modules/master/src/main/java/com/klaro/acquiring/master/dto/MerchantRequest.java new file mode 100644 index 0000000..1a0ae5f --- /dev/null +++ b/modules/master/src/main/java/com/klaro/acquiring/master/dto/MerchantRequest.java @@ -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) { +} diff --git a/modules/master/src/main/java/com/klaro/acquiring/master/dto/MerchantResponse.java b/modules/master/src/main/java/com/klaro/acquiring/master/dto/MerchantResponse.java new file mode 100644 index 0000000..d28190d --- /dev/null +++ b/modules/master/src/main/java/com/klaro/acquiring/master/dto/MerchantResponse.java @@ -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()); + } +} diff --git a/modules/payment/pom.xml b/modules/payment/pom.xml new file mode 100644 index 0000000..01775f7 --- /dev/null +++ b/modules/payment/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + com.klaro.acquiring + card-acquiring-boot + 1.0.0 + ../../pom.xml + + payment + payment + + + + com.klaro.acquiring + common-framework + + + com.klaro.acquiring + domain + + + com.klaro.acquiring + persistence + + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/modules/payment/src/main/java/com/klaro/acquiring/payment/PaymentService.java b/modules/payment/src/main/java/com/klaro/acquiring/payment/PaymentService.java new file mode 100644 index 0000000..c4c3cac --- /dev/null +++ b/modules/payment/src/main/java/com/klaro/acquiring/payment/PaymentService.java @@ -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 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 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 listByPayDate(LocalDate payDate) { + return paymentRepository.findByPayDate(payDate); + } + + /** 지급 파일 생성 결과. */ + public record PaymentFile(LocalDate payDate, int count, BigDecimal totalAmount, String content) { + } +} diff --git a/modules/reconcile/pom.xml b/modules/reconcile/pom.xml new file mode 100644 index 0000000..0bf485b --- /dev/null +++ b/modules/reconcile/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + com.klaro.acquiring + card-acquiring-boot + 1.0.0 + ../../pom.xml + + reconcile + reconcile + + + + com.klaro.acquiring + common-framework + + + com.klaro.acquiring + domain + + + com.klaro.acquiring + persistence + + + org.springframework.boot + spring-boot-starter-batch + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/modules/reconcile/src/main/java/com/klaro/acquiring/reconcile/ReconcileMatcher.java b/modules/reconcile/src/main/java/com/klaro/acquiring/reconcile/ReconcileMatcher.java new file mode 100644 index 0000000..2fdde50 --- /dev/null +++ b/modules/reconcile/src/main/java/com/klaro/acquiring/reconcile/ReconcileMatcher.java @@ -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 매칭기 (순수 로직 — 단위 테스트 대상). + * + *

승인금액 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; + } +} diff --git a/modules/reconcile/src/main/java/com/klaro/acquiring/reconcile/ReconcileService.java b/modules/reconcile/src/main/java/com/klaro/acquiring/reconcile/ReconcileService.java new file mode 100644 index 0000000..12831ab --- /dev/null +++ b/modules/reconcile/src/main/java/com/klaro/acquiring/reconcile/ReconcileService.java @@ -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 = purchaseRepository.findByApprNo(approval.getApprNo()); + Optional 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 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 results(LocalDate bizDate) { + return reconcileRepository.findByBizDate(bizDate); + } + + /** 대사 요약. */ + public record ReconcileSummary(LocalDate bizDate, long total, long matched, long unmatched) { + } +} diff --git a/modules/reconcile/src/main/java/com/klaro/acquiring/reconcile/batch/ReconcileJobConfig.java b/modules/reconcile/src/main/java/com/klaro/acquiring/reconcile/batch/ReconcileJobConfig.java new file mode 100644 index 0000000..7434f38 --- /dev/null +++ b/modules/reconcile/src/main/java/com/klaro/acquiring/reconcile/batch/ReconcileJobConfig.java @@ -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 reconcileApprovalReader( + @Value("#{jobParameters['bizDate']}") String bizDate) { + LocalDate date = DateUtil.parse(bizDate); + return new IteratorItemReader<>(approvalRepository.findByBizDate(date)); + } + + @Bean + @StepScope + public ItemProcessor reconcileProcessor() { + return approval -> reconcileService.reconcileOne(approval, ReconcileMatcher.DEFAULT_TOLERANCE); + } + + @Bean + public ItemWriter reconcileWriter() { + return items -> reconcileRepository.saveAll(items); + } + + @Bean + public Step reconcileStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ItemReader reconcileApprovalReader, + ItemProcessor reconcileProcessor, + ItemWriter reconcileWriter) { + return new StepBuilder("reconcileStep", jobRepository) + .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(); + } +} diff --git a/modules/reconcile/src/test/java/com/klaro/acquiring/reconcile/ReconcileMatcherTest.java b/modules/reconcile/src/test/java/com/klaro/acquiring/reconcile/ReconcileMatcherTest.java new file mode 100644 index 0000000..89bf06c --- /dev/null +++ b/modules/reconcile/src/test/java/com/klaro/acquiring/reconcile/ReconcileMatcherTest.java @@ -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("금액 불일치"); + } +} diff --git a/modules/settlement/pom.xml b/modules/settlement/pom.xml new file mode 100644 index 0000000..8ec33b9 --- /dev/null +++ b/modules/settlement/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + com.klaro.acquiring + card-acquiring-boot + 1.0.0 + ../../pom.xml + + settlement + settlement + + + + com.klaro.acquiring + common-framework + + + com.klaro.acquiring + domain + + + com.klaro.acquiring + persistence + + + org.springframework.boot + spring-boot-starter-batch + + + com.klaro.acquiring + acquiring + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/modules/settlement/src/main/java/com/klaro/acquiring/settlement/SettlementService.java b/modules/settlement/src/main/java/com/klaro/acquiring/settlement/SettlementService.java new file mode 100644 index 0000000..492cfee --- /dev/null +++ b/modules/settlement/src/main/java/com/klaro/acquiring/settlement/SettlementService.java @@ -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 서비스. + * + *

가맹점·영업일 단위로 매입완료(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 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 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 listByBizDate(LocalDate bizDate) { + return settlementRepository.findByBizDate(bizDate); + } +} diff --git a/modules/settlement/src/main/java/com/klaro/acquiring/settlement/batch/SettlementJobConfig.java b/modules/settlement/src/main/java/com/klaro/acquiring/settlement/batch/SettlementJobConfig.java new file mode 100644 index 0000000..2aaba73 --- /dev/null +++ b/modules/settlement/src/main/java/com/klaro/acquiring/settlement/batch/SettlementJobConfig.java @@ -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 settlementMerchantReader() { + return new IteratorItemReader<>(merchantRepository.findAll()); + } + + @Bean + @StepScope + public ItemProcessor settlementProcessor( + @Value("#{jobParameters['bizDate']}") String bizDate) { + LocalDate date = DateUtil.parse(bizDate); + return merchant -> settlementService.compute(merchant, date); + } + + @Bean + public ItemWriter settlementWriter() { + return items -> { + for (Settlement s : items) { + settlementService.save(s); + } + }; + } + + @Bean + public Step settlementStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ItemReader settlementMerchantReader, + ItemProcessor settlementProcessor, + ItemWriter settlementWriter) { + return new StepBuilder("settlementStep", jobRepository) + .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(); + } +} diff --git a/persistence/pom.xml b/persistence/pom.xml new file mode 100644 index 0000000..1b2de81 --- /dev/null +++ b/persistence/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + com.klaro.acquiring + card-acquiring-boot + 1.0.0 + + persistence + persistence + Spring Data JPA 리포지토리 + Flyway 스키마 + H2 + + + + com.klaro.acquiring + domain + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.flywaydb + flyway-core + + + com.h2database + h2 + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/persistence/src/main/java/com/klaro/acquiring/persistence/repository/ApprovalRepository.java b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/ApprovalRepository.java new file mode 100644 index 0000000..d690f1f --- /dev/null +++ b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/ApprovalRepository.java @@ -0,0 +1,15 @@ +package com.klaro.acquiring.persistence.repository; + +import com.klaro.acquiring.domain.entity.Approval; +import com.klaro.acquiring.domain.enums.TxStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface ApprovalRepository extends JpaRepository { + Optional findByApprNo(String apprNo); + List findByBizDateAndStatus(LocalDate bizDate, TxStatus status); + List findByBizDate(LocalDate bizDate); +} diff --git a/persistence/src/main/java/com/klaro/acquiring/persistence/repository/CardRepository.java b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/CardRepository.java new file mode 100644 index 0000000..ae796f5 --- /dev/null +++ b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/CardRepository.java @@ -0,0 +1,7 @@ +package com.klaro.acquiring.persistence.repository; + +import com.klaro.acquiring.domain.entity.Card; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CardRepository extends JpaRepository { +} diff --git a/persistence/src/main/java/com/klaro/acquiring/persistence/repository/DepositRepository.java b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/DepositRepository.java new file mode 100644 index 0000000..54341ac --- /dev/null +++ b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/DepositRepository.java @@ -0,0 +1,13 @@ +package com.klaro.acquiring.persistence.repository; + +import com.klaro.acquiring.domain.entity.Deposit; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface DepositRepository extends JpaRepository { + Optional findByApprNo(String apprNo); + List findByBizDate(LocalDate bizDate); +} diff --git a/persistence/src/main/java/com/klaro/acquiring/persistence/repository/FeeRepository.java b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/FeeRepository.java new file mode 100644 index 0000000..228ac37 --- /dev/null +++ b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/FeeRepository.java @@ -0,0 +1,10 @@ +package com.klaro.acquiring.persistence.repository; + +import com.klaro.acquiring.domain.entity.Fee; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface FeeRepository extends JpaRepository { + List findByPurchaseId(Long purchaseId); +} diff --git a/persistence/src/main/java/com/klaro/acquiring/persistence/repository/LedgerEntryRepository.java b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/LedgerEntryRepository.java new file mode 100644 index 0000000..164027f --- /dev/null +++ b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/LedgerEntryRepository.java @@ -0,0 +1,12 @@ +package com.klaro.acquiring.persistence.repository; + +import com.klaro.acquiring.domain.entity.LedgerEntry; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface LedgerEntryRepository extends JpaRepository { + List findByMerchIdAndBizDate(String merchId, LocalDate bizDate); + List findByBizDate(LocalDate bizDate); +} diff --git a/persistence/src/main/java/com/klaro/acquiring/persistence/repository/MerchantRepository.java b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/MerchantRepository.java new file mode 100644 index 0000000..fd77f7a --- /dev/null +++ b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/MerchantRepository.java @@ -0,0 +1,11 @@ +package com.klaro.acquiring.persistence.repository; + +import com.klaro.acquiring.domain.entity.Merchant; +import com.klaro.acquiring.domain.enums.MerchantStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MerchantRepository extends JpaRepository { + List findByStatus(MerchantStatus status); +} diff --git a/persistence/src/main/java/com/klaro/acquiring/persistence/repository/PaymentRepository.java b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/PaymentRepository.java new file mode 100644 index 0000000..5afa8e1 --- /dev/null +++ b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/PaymentRepository.java @@ -0,0 +1,14 @@ +package com.klaro.acquiring.persistence.repository; + +import com.klaro.acquiring.domain.entity.Payment; +import com.klaro.acquiring.domain.enums.PaymentStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface PaymentRepository extends JpaRepository { + List findByPayDate(LocalDate payDate); + List findByPayDateAndStatus(LocalDate payDate, PaymentStatus status); + List findByMerchId(String merchId); +} diff --git a/persistence/src/main/java/com/klaro/acquiring/persistence/repository/PurchaseRepository.java b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/PurchaseRepository.java new file mode 100644 index 0000000..d999d4a --- /dev/null +++ b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/PurchaseRepository.java @@ -0,0 +1,18 @@ +package com.klaro.acquiring.persistence.repository; + +import com.klaro.acquiring.domain.entity.Purchase; +import com.klaro.acquiring.domain.enums.TxStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface PurchaseRepository extends JpaRepository { + boolean existsByApprNo(String apprNo); + Optional findByApprNo(String apprNo); + List findByBizDateAndStatus(LocalDate bizDate, TxStatus status); + List findByMerchIdAndBizDateAndStatus(String merchId, LocalDate bizDate, TxStatus status); + List findByMerchIdAndBizDate(String merchId, LocalDate bizDate); + List findByBizDate(LocalDate bizDate); +} diff --git a/persistence/src/main/java/com/klaro/acquiring/persistence/repository/ReconcileResultRepository.java b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/ReconcileResultRepository.java new file mode 100644 index 0000000..bc32098 --- /dev/null +++ b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/ReconcileResultRepository.java @@ -0,0 +1,14 @@ +package com.klaro.acquiring.persistence.repository; + +import com.klaro.acquiring.domain.entity.ReconcileResult; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface ReconcileResultRepository extends JpaRepository { + List findByBizDate(LocalDate bizDate); + List findByBizDateAndMatched(LocalDate bizDate, boolean matched); + Optional findByBizDateAndApprNo(LocalDate bizDate, String apprNo); +} diff --git a/persistence/src/main/java/com/klaro/acquiring/persistence/repository/SettlementRepository.java b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/SettlementRepository.java new file mode 100644 index 0000000..fef99fd --- /dev/null +++ b/persistence/src/main/java/com/klaro/acquiring/persistence/repository/SettlementRepository.java @@ -0,0 +1,14 @@ +package com.klaro.acquiring.persistence.repository; + +import com.klaro.acquiring.domain.entity.Settlement; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface SettlementRepository extends JpaRepository { + Optional findByMerchIdAndBizDate(String merchId, LocalDate bizDate); + List findByBizDate(LocalDate bizDate); + List findByMerchId(String merchId); +} diff --git a/persistence/src/main/resources/db/migration/V1__schema.sql b/persistence/src/main/resources/db/migration/V1__schema.sql new file mode 100644 index 0000000..2701b54 --- /dev/null +++ b/persistence/src/main/resources/db/migration/V1__schema.sql @@ -0,0 +1,135 @@ +-- V1__schema.sql +-- 카드 매입·정산 스키마. 레거시 acquire-core-full/db/schema.sql + 모듈별 *_ledger 를 +-- 정규화하여 재구성. 상태코드는 enum name(VARCHAR)으로 저장(RECEIVED/DONE/FAILED/SETTLED). +-- H2/PostgreSQL 공통 호환 DDL. + +CREATE TABLE merchant ( + merch_id VARCHAR(15) NOT NULL, + merch_name VARCHAR(60) NOT NULL, + biz_no VARCHAR(12), + status VARCHAR(12) NOT NULL DEFAULT 'ACTIVE', + mdr_rate DECIMAL(6,4) NOT NULL DEFAULT 0, + van_fee DECIMAL(18,2) NOT NULL DEFAULT 0, + settlement_cycle INT NOT NULL DEFAULT 2, + bank_code VARCHAR(3), + account_no VARCHAR(20), + CONSTRAINT pk_merchant PRIMARY KEY (merch_id) +); + +CREATE TABLE card ( + card_no VARCHAR(19) NOT NULL, + bin VARCHAR(6) NOT NULL, + brand VARCHAR(10) NOT NULL, + issuer_code VARCHAR(4), + status VARCHAR(1) NOT NULL DEFAULT 'A', + CONSTRAINT pk_card PRIMARY KEY (card_no) +); + +CREATE TABLE approval ( + appr_no VARCHAR(12) NOT NULL, + merch_id VARCHAR(15) NOT NULL, + card_no VARCHAR(19) NOT NULL, + amount DECIMAL(18,2) NOT NULL, + appr_date VARCHAR(8) NOT NULL, + biz_date DATE NOT NULL, + status VARCHAR(12) NOT NULL DEFAULT 'RECEIVED', + CONSTRAINT pk_approval PRIMARY KEY (appr_no) +); +CREATE INDEX ix_approval_biz ON approval (biz_date, status); + +CREATE TABLE purchase ( + purchase_id BIGINT GENERATED BY DEFAULT AS IDENTITY, + appr_no VARCHAR(12) NOT NULL, + merch_id VARCHAR(15) NOT NULL, + card_no VARCHAR(19) NOT NULL, + amount DECIMAL(18,2) NOT NULL, + mdr_fee DECIMAL(18,2) NOT NULL DEFAULT 0, + van_fee DECIMAL(18,2) NOT NULL DEFAULT 0, + net_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + biz_date DATE NOT NULL, + status VARCHAR(12) NOT NULL DEFAULT 'RECEIVED', + CONSTRAINT pk_purchase PRIMARY KEY (purchase_id), + CONSTRAINT uq_purchase_appr UNIQUE (appr_no) +); +CREATE INDEX ix_purchase_biz ON purchase (biz_date, status); +CREATE INDEX ix_purchase_merch ON purchase (merch_id, biz_date); + +CREATE TABLE deposit ( + deposit_id BIGINT GENERATED BY DEFAULT AS IDENTITY, + appr_no VARCHAR(12) NOT NULL, + merch_id VARCHAR(15) NOT NULL, + amount DECIMAL(18,2) NOT NULL, + biz_date DATE NOT NULL, + CONSTRAINT pk_deposit PRIMARY KEY (deposit_id) +); +CREATE INDEX ix_deposit_appr ON deposit (appr_no); + +CREATE TABLE fee ( + fee_id BIGINT GENERATED BY DEFAULT AS IDENTITY, + purchase_id BIGINT NOT NULL, + merch_id VARCHAR(15) NOT NULL, + fee_type VARCHAR(8) NOT NULL, + base_amount DECIMAL(18,2) NOT NULL, + rate DECIMAL(6,4) NOT NULL DEFAULT 0, + fee_amount DECIMAL(18,2) NOT NULL, + biz_date DATE NOT NULL, + CONSTRAINT pk_fee PRIMARY KEY (fee_id) +); +CREATE INDEX ix_fee_purchase ON fee (purchase_id); + +CREATE TABLE reconcile_result ( + recon_id BIGINT GENERATED BY DEFAULT AS IDENTITY, + biz_date DATE NOT NULL, + appr_no VARCHAR(12) NOT NULL, + merch_id VARCHAR(15) NOT NULL, + approval_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + purchase_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + deposit_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + diff_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + matched BOOLEAN NOT NULL DEFAULT FALSE, + status VARCHAR(12) NOT NULL DEFAULT 'RECEIVED', + reason VARCHAR(100), + CONSTRAINT pk_reconcile PRIMARY KEY (recon_id) +); +CREATE INDEX ix_reconcile_biz ON reconcile_result (biz_date); + +CREATE TABLE settlement ( + settlement_id BIGINT GENERATED BY DEFAULT AS IDENTITY, + merch_id VARCHAR(15) NOT NULL, + biz_date DATE NOT NULL, + gross_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + total_fee DECIMAL(18,2) NOT NULL DEFAULT 0, + net_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + txn_count INT NOT NULL DEFAULT 0, + pay_date DATE NOT NULL, + status VARCHAR(12) NOT NULL DEFAULT 'DONE', + CONSTRAINT pk_settlement PRIMARY KEY (settlement_id), + CONSTRAINT uq_settlement_merch_date UNIQUE (merch_id, biz_date) +); +CREATE INDEX ix_settlement_biz ON settlement (biz_date); + +CREATE TABLE ledger_entry ( + entry_id BIGINT GENERATED BY DEFAULT AS IDENTITY, + merch_id VARCHAR(15) NOT NULL, + biz_date DATE NOT NULL, + entry_type VARCHAR(12) NOT NULL, + dr_cr VARCHAR(8) NOT NULL, + amount DECIMAL(18,2) NOT NULL, + ref_type VARCHAR(20), + ref_id VARCHAR(30), + CONSTRAINT pk_ledger_entry PRIMARY KEY (entry_id) +); +CREATE INDEX ix_ledger_merch ON ledger_entry (merch_id, biz_date); + +CREATE TABLE payment ( + payment_id BIGINT GENERATED BY DEFAULT AS IDENTITY, + settlement_id BIGINT NOT NULL, + merch_id VARCHAR(15) NOT NULL, + pay_date DATE NOT NULL, + amount DECIMAL(18,2) NOT NULL, + bank_code VARCHAR(3), + account_no VARCHAR(20), + status VARCHAR(15) NOT NULL DEFAULT 'READY', + CONSTRAINT pk_payment PRIMARY KEY (payment_id) +); +CREATE INDEX ix_payment_paydate ON payment (pay_date, status); diff --git a/persistence/src/main/resources/db/migration/V2__seed.sql b/persistence/src/main/resources/db/migration/V2__seed.sql new file mode 100644 index 0000000..2137039 --- /dev/null +++ b/persistence/src/main/resources/db/migration/V2__seed.sql @@ -0,0 +1,12 @@ +-- V2__seed.sql - 최소 참조/시드 데이터 +-- 가맹점 3개(수수료율/정산주기/지급계좌 상이), 카드 3개. + +INSERT INTO merchant (merch_id, merch_name, biz_no, status, mdr_rate, van_fee, settlement_cycle, bank_code, account_no) VALUES + ('M0000000000001', '한빛마트', '111-11-11111', 'ACTIVE', 0.0230, 30.00, 2, '004', '11122233344455'), + ('M0000000000002', '가온카페', '222-22-22222', 'ACTIVE', 0.0180, 25.00, 3, '088', '22233344455566'), + ('M0000000000003', '다올주유소', '333-33-33333', 'ACTIVE', 0.0150, 20.00, 1, '020', '33344455566677'); + +INSERT INTO card (card_no, bin, brand, issuer_code, status) VALUES + ('123456******7890', '123456', 'LOCAL', '0301', 'A'), + ('654321******0987', '654321', 'VISA', '0302', 'A'), + ('987654******3210', '987654', 'MASTER','0303', 'A'); diff --git a/persistence/src/test/java/com/klaro/acquiring/persistence/PersistenceTestApp.java b/persistence/src/test/java/com/klaro/acquiring/persistence/PersistenceTestApp.java new file mode 100644 index 0000000..a6957a2 --- /dev/null +++ b/persistence/src/test/java/com/klaro/acquiring/persistence/PersistenceTestApp.java @@ -0,0 +1,14 @@ +package com.klaro.acquiring.persistence; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** persistence 모듈 슬라이스 테스트용 부트 설정. */ +@SpringBootConfiguration +@EnableAutoConfiguration +@EntityScan(basePackages = "com.klaro.acquiring.domain.entity") +@EnableJpaRepositories(basePackages = "com.klaro.acquiring.persistence.repository") +public class PersistenceTestApp { +} diff --git a/persistence/src/test/java/com/klaro/acquiring/persistence/repository/MerchantRepositoryTest.java b/persistence/src/test/java/com/klaro/acquiring/persistence/repository/MerchantRepositoryTest.java new file mode 100644 index 0000000..318d9ff --- /dev/null +++ b/persistence/src/test/java/com/klaro/acquiring/persistence/repository/MerchantRepositoryTest.java @@ -0,0 +1,46 @@ +package com.klaro.acquiring.persistence.repository; + +import com.klaro.acquiring.domain.entity.Approval; +import com.klaro.acquiring.domain.entity.Merchant; +import com.klaro.acquiring.domain.enums.MerchantStatus; +import com.klaro.acquiring.domain.enums.TxStatus; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class MerchantRepositoryTest { + + @Autowired + private MerchantRepository merchantRepository; + + @Autowired + private ApprovalRepository approvalRepository; + + @Test + void flywaySeedLoadsThreeMerchants() { + List active = merchantRepository.findByStatus(MerchantStatus.ACTIVE); + assertThat(active).hasSize(3); + Merchant m = merchantRepository.findById("M0000000000001").orElseThrow(); + assertThat(m.getMdrRate()).isEqualByComparingTo("0.0230"); + assertThat(m.getSettlementCycle()).isEqualTo(2); + } + + @Test + void persistsAndQueriesApprovalByBizDate() { + LocalDate bizDate = LocalDate.of(2026, 7, 17); + approvalRepository.save(new Approval("A0001", "M0000000000001", "123456******7890", + new BigDecimal("10000.00"), "20260717", bizDate)); + + List found = approvalRepository.findByBizDateAndStatus(bizDate, TxStatus.RECEIVED); + assertThat(found).extracting(Approval::getApprNo).containsExactly("A0001"); + } +} diff --git a/persistence/src/test/resources/application.yml b/persistence/src/test/resources/application.yml new file mode 100644 index 0000000..79ad996 --- /dev/null +++ b/persistence/src/test/resources/application.yml @@ -0,0 +1,15 @@ +spring: + datasource: + url: jdbc:h2:mem:persist;DB_CLOSE_DELAY=-1;MODE=PostgreSQL + driver-class-name: org.h2.Driver + username: sa + password: "" + jpa: + hibernate: + ddl-auto: none + open-in-view: false + properties: + hibernate.format_sql: false + flyway: + enabled: true + locations: classpath:db/migration diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6c3c2dd --- /dev/null +++ b/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + com.klaro.acquiring + card-acquiring-boot + 1.0.0 + pom + card-acquiring-boot + 카드 매입·정산 시스템 (acquire-core 레거시의 Spring Boot 이관 타깃) + + + 17 + 17 + UTF-8 + + + + common-framework + domain + persistence + modules/gateway + modules/acquiring + modules/reconcile + modules/settlement + modules/payment + modules/ledger + modules/master + modules/closing + app + + + + + + com.klaro.acquiring + common-framework + ${project.version} + + + com.klaro.acquiring + domain + ${project.version} + + + com.klaro.acquiring + persistence + ${project.version} + + + com.klaro.acquiring + gateway + ${project.version} + + + com.klaro.acquiring + acquiring + ${project.version} + + + com.klaro.acquiring + reconcile + ${project.version} + + + com.klaro.acquiring + settlement + ${project.version} + + + com.klaro.acquiring + payment + ${project.version} + + + com.klaro.acquiring + ledger + ${project.version} + + + com.klaro.acquiring + master + ${project.version} + + + com.klaro.acquiring + closing + ${project.version} + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + +