feat: complete Spring Boot 카드 매입·정산 system (acquire-core migration target, mvn verify green, 24 tests)
This commit is contained in:
commit
0f6e3acfe6
92 changed files with 4320 additions and 0 deletions
34
common-framework/pom.xml
Normal file
34
common-framework/pom.xml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.klaro.acquiring</groupId>
|
||||
<artifactId>card-acquiring-boot</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>common-framework</artifactId>
|
||||
<name>common-framework</name>
|
||||
<description>TxCore 프레임워크의 Spring 대체: TxContext, MessageCodec(전문 codec), TxTemplate, 유틸, 예외체계</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-tx</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.klaro.acquiring.common.codec;
|
||||
|
||||
/**
|
||||
* 전문 필드 유형. 고정길이 인코딩 규칙을 결정한다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #ALPHANUMERIC} : 좌측정렬, 우측 공백 패딩 (문자열)</li>
|
||||
* <li>{@link #NUMERIC} : 우측정렬, 좌측 0 패딩 (금액/숫자)</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum FieldType {
|
||||
ALPHANUMERIC,
|
||||
NUMERIC
|
||||
}
|
||||
|
|
@ -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) 변환한다.
|
||||
*
|
||||
* <p>인코딩 규칙:
|
||||
* <ul>
|
||||
* <li>ALPHANUMERIC : 좌측정렬 후 우측 공백 패딩, 초과 시 잘림</li>
|
||||
* <li>NUMERIC : 우측정렬 후 좌측 0 패딩, 초과 시 하위자리 유지(왼쪽 잘림)</li>
|
||||
* </ul>
|
||||
* 디코딩 시 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<String, String> 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<String, String> 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<String, String> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.klaro.acquiring.common.codec;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 전문 레이아웃(고정길이 필드의 순서 있는 목록). FML 필드 테이블 전체에 대응.
|
||||
*/
|
||||
public record MessageSpec(String name, List<FieldSpec> 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.klaro.acquiring.common.dto;
|
||||
|
||||
import com.klaro.acquiring.common.error.ErrorCode;
|
||||
|
||||
/**
|
||||
* 표준 응답 봉투. 레거시 응답 TXBUF 의 RESPCODE/데이터 구성을 대체.
|
||||
*/
|
||||
public record ApiResponse<T>(String code, String message, T data) {
|
||||
|
||||
public static <T> ApiResponse<T> ok(T data) {
|
||||
return new ApiResponse<>(ErrorCode.OK.code(), ErrorCode.OK.defaultMessage(), data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(ErrorCode code, String message) {
|
||||
return new ApiResponse<>(code.code(), message, null);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TxContext> 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 + '}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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> T execute(Supplier<T> work) {
|
||||
return transactionTemplate.execute(status -> work.get());
|
||||
}
|
||||
|
||||
/** 반환값 없는 트랜잭션 실행. */
|
||||
public void run(Runnable work) {
|
||||
transactionTemplate.executeWithoutResult(status -> work.run());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> 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<String, String> v = new LinkedHashMap<>();
|
||||
v.put("KEY", "A123");
|
||||
v.put("MERCHID", "M0001");
|
||||
v.put("AMOUNT", "10000");
|
||||
v.put("BIZDATE", "20260717");
|
||||
v.put("STATUS", "R");
|
||||
|
||||
Map<String, String> 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<String, String> 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("전문 길이 부족");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Reference in a new issue