From 0a0ea3ddb0623e82f0e105fd2d4cbc51989964c4 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Wed, 22 Jul 2026 17:05:48 +0900 Subject: [PATCH 01/13] fix(core): enforce cost amount and currency invariants --- .../java/io/tokenledger/core/domain/Cost.java | 41 +++++++++---- .../tokenledger/core/domain/PricingPlan.java | 4 +- .../core/internal/DefaultLedgerManager.java | 5 +- .../io/tokenledger/core/domain/CostTest.java | 61 +++++++++++++++++++ ...ava => DefaultCostTestCalculatorTest.java} | 14 +++-- .../internal/DefaultLedgerManagerTest.java | 4 +- 6 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java rename token-ledger-core/src/test/java/io/tokenledger/core/internal/{DefaultCostCalculatorTest.java => DefaultCostTestCalculatorTest.java} (88%) diff --git a/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java b/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java index 51088c0..8bc7bbf 100644 --- a/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java +++ b/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java @@ -3,35 +3,52 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Currency; +import java.util.Objects; /** * 계산된 AI 호출 비용 정보. * - * @param value 비용 (BigDecimal, 소수점 6자리 권장) + * @param amount 비용 (BigDecimal, 소수점 6자리 권장) * @param currency 통화 (기본값: USD) */ public record Cost( - BigDecimal value, + BigDecimal amount, Currency currency ) { - public static final Currency DEFAULT_CURRENCY = Currency.getInstance("USD"); public Cost { - value = value.setScale(6, RoundingMode.HALF_UP); + Objects.requireNonNull(amount, "amount cant be null"); + Objects.requireNonNull(currency, "currency cant be null"); + + validateNonNegativeAmount(amount); + } + + private static void validateNonNegativeAmount(BigDecimal amount) { + if (amount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Cost value must not be negative"); + } } - public static Cost of(BigDecimal value) { - return new Cost(value, DEFAULT_CURRENCY); + public static Cost of(BigDecimal value, Currency currency) { + return new Cost(value, currency); } - public static Cost zero() { - return of(BigDecimal.ZERO); + public static Cost zero(Currency currency) { + return of(BigDecimal.ZERO, currency); } public Cost add(Cost other) { - if (!this.currency.equals(other.currency)) { - throw new IllegalArgumentException("Cannot add costs with different currencies"); + Objects.requireNonNull(other, "cost cant be null"); + validateSameCurrency(other); + + BigDecimal totalAmount = amount.add(other.amount); + + return new Cost(totalAmount, currency); + } + + private void validateSameCurrency(Cost other) { + if (!currency.equals(other.currency)) { + throw new IllegalArgumentException("Cannot operate on costs with different currencies"); } - return new Cost(this.value.add(other.value), this.currency); } -} +} \ No newline at end of file diff --git a/token-ledger-core/src/main/java/io/tokenledger/core/domain/PricingPlan.java b/token-ledger-core/src/main/java/io/tokenledger/core/domain/PricingPlan.java index cfe3b8c..ec5cd05 100644 --- a/token-ledger-core/src/main/java/io/tokenledger/core/domain/PricingPlan.java +++ b/token-ledger-core/src/main/java/io/tokenledger/core/domain/PricingPlan.java @@ -19,10 +19,12 @@ public record PricingPlan( Map rates, Currency currency ) { + static String DEFAULT_CURRENCY = "USD"; + public PricingPlan { rates = Collections.unmodifiableMap(new EnumMap<>(rates)); if (currency == null) { - currency = Currency.getInstance("USD"); + currency = Currency.getInstance(DEFAULT_CURRENCY); } // 모든 단가는 0 이상이어야 함 rates.values().forEach(v -> { diff --git a/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultLedgerManager.java b/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultLedgerManager.java index 1a3db63..d6a53fb 100644 --- a/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultLedgerManager.java +++ b/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultLedgerManager.java @@ -3,6 +3,7 @@ import io.tokenledger.core.*; import io.tokenledger.core.domain.*; +import java.util.Currency; import java.util.List; import java.util.Map; import java.util.Optional; @@ -17,6 +18,8 @@ class DefaultLedgerManager implements LedgerManager { private final CostCalculator costCalculator; private final List listeners = new CopyOnWriteArrayList<>(); + static String DEFAULT_CURRENCY = "USD"; + public DefaultLedgerManager(PricingRegistry pricingRegistry, CostCalculator costCalculator) { this(pricingRegistry, costCalculator, List.of()); } @@ -44,7 +47,7 @@ public Cost record(String modelId, TokenUsage usage, Map tags) { Cost cost = planOpt .map(plan -> costCalculator.calculate(usage, plan)) - .orElse(Cost.zero()); + .orElse(Cost.zero(Currency.getInstance(DEFAULT_CURRENCY))); // 이벤트 발행 (리스너들에게 전파) if (!listeners.isEmpty()) { diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java new file mode 100644 index 0000000..8d8854e --- /dev/null +++ b/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java @@ -0,0 +1,61 @@ +package io.tokenledger.core.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.util.Currency; +import org.junit.jupiter.api.Test; + +public class CostTest { + + private static final Currency USD = Currency.getInstance("USD"); + private static final Currency KRW = Currency.getInstance("KRW"); + + @Test + void shouldRejectNullAmount() { + assertThatNullPointerException() + .isThrownBy(() -> new Cost(null, USD)); + } + + @Test + void shouldRejectNullCurrency() { + assertThatNullPointerException() + .isThrownBy(() -> new Cost(BigDecimal.ONE, null)); + } + + @Test + void shouldRejectNegativeUsageCost() { + assertThatThrownBy(() -> Cost.of(new BigDecimal("-0.01"), USD)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shouldPreserveCurrencyWhenCreatingZeroCost() { + Cost cost = Cost.zero(KRW); + + assertThat(cost.amount()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(cost.currency()).isEqualTo(KRW); + } + + @Test + void shouldAddCostsWithSameCurrency() { + Cost first = Cost.of(new BigDecimal("1.25"), USD); + Cost second = Cost.of(new BigDecimal("2.75"), USD); + + Cost result = first.add(second); + + assertThat(result.amount()).isEqualByComparingTo("4.00"); + assertThat(result.currency()).isEqualTo(USD); + } + + @Test + void shouldRejectAddWithDifferentCurrency() { + Cost usdCost = Cost.of(BigDecimal.ONE, USD); + Cost krwCost = Cost.of(BigDecimal.ONE, KRW); + + assertThatThrownBy(() -> usdCost.add(krwCost)) + .isInstanceOf(IllegalArgumentException.class); + } +} \ No newline at end of file diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostCalculatorTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostTestCalculatorTest.java similarity index 88% rename from token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostCalculatorTest.java rename to token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostTestCalculatorTest.java index b7737c4..7272fee 100644 --- a/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostCalculatorTest.java +++ b/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostTestCalculatorTest.java @@ -4,7 +4,7 @@ import io.tokenledger.core.domain.PricingPlan; import io.tokenledger.core.domain.TokenType; import io.tokenledger.core.domain.TokenUsage; -import org.junit.jupiter.api.BeforeEach; +import java.util.Currency; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,10 +14,12 @@ import static org.assertj.core.api.Assertions.assertThat; -class DefaultCostCalculatorTest { +class DefaultCostTestCalculatorTest { private final DefaultCostCalculator calculator = new DefaultCostCalculator(); + private static final String DEFAULT_CURRENCY = "USD"; + @Test @DisplayName("기본 입력/출력 토큰 비용이 정확하게 계산되어야 한다") void calculateStandardTokens() { @@ -30,7 +32,7 @@ void calculateStandardTokens() { Cost cost = calculator.calculate(usage, plan); // Then: (1000 * 0.01 / 1000) + (2000 * 0.03 / 1000) = 0.01 + 0.06 = 0.07 - assertThat(cost.value()).isEqualByComparingTo("0.070000"); + assertThat(cost.amount()).isEqualByComparingTo("0.070000"); } @Test @@ -41,7 +43,7 @@ void calculateReasoningTokensWithDifferentRate() { rates.put(TokenType.PROMPT, new BigDecimal("0.015")); rates.put(TokenType.COMPLETION, new BigDecimal("0.060")); rates.put(TokenType.REASONING, new BigDecimal("0.045")); - PricingPlan plan = new PricingPlan("o1-preview", rates, Cost.DEFAULT_CURRENCY); + PricingPlan plan = new PricingPlan("o1-preview", rates, Currency.getInstance(DEFAULT_CURRENCY)); // Usage: Prompt 1000, Completion 500, Reasoning 1500 (Total 3000) Map counts = new EnumMap<>(TokenType.class); @@ -58,7 +60,7 @@ void calculateReasoningTokensWithDifferentRate() { // 500 * 0.060 / 1000 = 0.030 // 1500 * 0.045 / 1000 = 0.0675 // Total = 0.015 + 0.030 + 0.0675 = 0.1125 - assertThat(cost.value()).isEqualByComparingTo("0.112500"); + assertThat(cost.amount()).isEqualByComparingTo("0.112500"); } @Test @@ -73,6 +75,6 @@ void calculateReasoningTokensWithFallback() { Cost cost = calculator.calculate(usage, plan); // Then: 1000 * 0.01 / 1000 + 1000 * 0.03 / 1000 = 0.04 - assertThat(cost.value()).isEqualByComparingTo("0.040000"); + assertThat(cost.amount()).isEqualByComparingTo("0.040000"); } } diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultLedgerManagerTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultLedgerManagerTest.java index f6e22ce..155a3d6 100644 --- a/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultLedgerManagerTest.java +++ b/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultLedgerManagerTest.java @@ -38,7 +38,7 @@ void shouldRecordAndCalculateCost() { Cost cost = manager.record("gpt-4o", usage, Map.of()); - assertThat(cost.value()).isEqualByComparingTo("20.000000"); + assertThat(cost.amount()).isEqualByComparingTo("20.000000"); verify(listener).onRecord(argThat(event -> event.modelId().equals("gpt-4o") && event.usage().equals(usage) && @@ -53,7 +53,7 @@ void shouldReturnZeroCostWhenPlanIsMissing() { Cost result = manager.record("unknown-model", usage, Map.of()); - assertThat(result.value()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.amount()).isEqualByComparingTo(BigDecimal.ZERO); verify(listener).onRecord(any(CostRecordedEvent.class)); } } From 67673b5f002218ec6bc351622322a79b04e52ca9 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Wed, 22 Jul 2026 17:17:50 +0900 Subject: [PATCH 02/13] fix(core): normalize cost equality across amount scales --- .../java/io/tokenledger/core/domain/Cost.java | 18 ++++++++++++++++++ .../io/tokenledger/core/domain/CostTest.java | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java b/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java index 8bc7bbf..f6eee6d 100644 --- a/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java +++ b/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java @@ -51,4 +51,22 @@ private void validateSameCurrency(Cost other) { throw new IllegalArgumentException("Cannot operate on costs with different currencies"); } } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof Cost other)) { + return false; + } + + return amount.compareTo(other.amount) == 0 + && currency.equals(other.currency); + } + + @Override + public int hashCode() { + return Objects.hash(amount.stripTrailingZeros(), currency); + } } \ No newline at end of file diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java index 8d8854e..45893a9 100644 --- a/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java +++ b/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java @@ -58,4 +58,21 @@ void shouldRejectAddWithDifferentCurrency() { assertThatThrownBy(() -> usdCost.add(krwCost)) .isInstanceOf(IllegalArgumentException.class); } + + @Test + void shouldTreatNumericallyEqualAmountsAsEqualRegardlessOfScale() { + Cost oneDecimal = Cost.of(new BigDecimal("1.0"), USD); + Cost twoDecimals = Cost.of(new BigDecimal("1.00"), USD); + + assertThat(oneDecimal).isEqualTo(twoDecimals); + assertThat(oneDecimal.hashCode()).isEqualTo(twoDecimals.hashCode()); + } + + @Test + void shouldNotTreatSameAmountWithDifferentCurrencyAsEqual() { + Cost usdCost = Cost.of(new BigDecimal("1.00"), USD); + Cost krwCost = Cost.of(new BigDecimal("1.00"), KRW); + + assertThat(usdCost).isNotEqualTo(krwCost); + } } \ No newline at end of file From 8690d458580b0b496b4452294048764e93225ab0 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 23 Jul 2026 13:38:58 +0900 Subject: [PATCH 03/13] test(core): clarify cost calculator test descriptions --- .../io/tokenledger/core/domain/CostTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java index 45893a9..08e6785 100644 --- a/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java +++ b/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java @@ -6,6 +6,7 @@ import java.math.BigDecimal; import java.util.Currency; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; public class CostTest { @@ -14,24 +15,28 @@ public class CostTest { private static final Currency KRW = Currency.getInstance("KRW"); @Test + @DisplayName("amount null을 거부한다.") void shouldRejectNullAmount() { assertThatNullPointerException() .isThrownBy(() -> new Cost(null, USD)); } @Test + @DisplayName("currency null을 거부한다.\n") void shouldRejectNullCurrency() { assertThatNullPointerException() .isThrownBy(() -> new Cost(BigDecimal.ONE, null)); } @Test + @DisplayName("usage cost는 음수를 거부한다.\n") void shouldRejectNegativeUsageCost() { assertThatThrownBy(() -> Cost.of(new BigDecimal("-0.01"), USD)) .isInstanceOf(IllegalArgumentException.class); } @Test + @DisplayName("0 값을 만들 때 통화를 보존해야 한다.") void shouldPreserveCurrencyWhenCreatingZeroCost() { Cost cost = Cost.zero(KRW); @@ -40,6 +45,7 @@ void shouldPreserveCurrencyWhenCreatingZeroCost() { } @Test + @DisplayName("동일한 통화로 비용을 추가해야 한다.") void shouldAddCostsWithSameCurrency() { Cost first = Cost.of(new BigDecimal("1.25"), USD); Cost second = Cost.of(new BigDecimal("2.75"), USD); @@ -51,6 +57,7 @@ void shouldAddCostsWithSameCurrency() { } @Test + @DisplayName("다른 통화로 비용을 추가하면 거부해야 한다.") void shouldRejectAddWithDifferentCurrency() { Cost usdCost = Cost.of(BigDecimal.ONE, USD); Cost krwCost = Cost.of(BigDecimal.ONE, KRW); @@ -60,6 +67,7 @@ void shouldRejectAddWithDifferentCurrency() { } @Test + @DisplayName("수치적으로 동일한 금액은 동일하게 취급해야 한다.") void shouldTreatNumericallyEqualAmountsAsEqualRegardlessOfScale() { Cost oneDecimal = Cost.of(new BigDecimal("1.0"), USD); Cost twoDecimals = Cost.of(new BigDecimal("1.00"), USD); @@ -69,10 +77,20 @@ void shouldTreatNumericallyEqualAmountsAsEqualRegardlessOfScale() { } @Test + @DisplayName("수치적으로 동일하지만 다른 통화를 동일하게 취급하면 안된다.") void shouldNotTreatSameAmountWithDifferentCurrencyAsEqual() { Cost usdCost = Cost.of(new BigDecimal("1.00"), USD); Cost krwCost = Cost.of(new BigDecimal("1.00"), KRW); assertThat(usdCost).isNotEqualTo(krwCost); } + + @Test + @DisplayName("0.0000004 비용이 Cost 생성 시 0이 되지 않는다.") + void shouldTreatZeroCostAsZero() { + Cost cost = Cost.of(new BigDecimal("0.0000004"), USD); + + assertThat(cost.amount()).isEqualByComparingTo("0.0000004"); + assertThat(cost.amount()).isNotEqualByComparingTo(BigDecimal.ZERO); + } } \ No newline at end of file From b9efa6f7f650a755cb538177c7ca71c688ce3815 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 23 Jul 2026 14:29:56 +0900 Subject: [PATCH 04/13] fix(core): calculate costs with decimal shift --- .../core/internal/DefaultCostCalculator.java | 14 ++++++-------- ...Test.java => DefaultCostCalculatorTest.java} | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 9 deletions(-) rename token-ledger-core/src/test/java/io/tokenledger/core/internal/{DefaultCostTestCalculatorTest.java => DefaultCostCalculatorTest.java} (86%) diff --git a/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultCostCalculator.java b/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultCostCalculator.java index 318e566..8db7d32 100644 --- a/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultCostCalculator.java +++ b/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultCostCalculator.java @@ -7,31 +7,29 @@ import io.tokenledger.core.domain.TokenUsage; import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.Map; /** - * 기본 비용 계산기 구현체. - * 각 {@link TokenType} 별 단가를 적용하여 정밀하게 계산합니다. - * 1K 토큰당 가격 정보를 사용하여 소수점 10자리까지 중간 계산 후 6자리로 최종 반올림합니다. + * 기본 비용 계산기 구현체. 각 {@link TokenType} 별 단가를 적용하여 정밀하게 계산합니다. */ class DefaultCostCalculator implements CostCalculator { - private static final BigDecimal THOUSAND = BigDecimal.valueOf(1000); @Override public Cost calculate(TokenUsage usage, PricingPlan plan) { BigDecimal totalCostValue = BigDecimal.ZERO; // 사용된 모든 토큰 타입에 대해 각각의 단가를 적용하여 합산 - for (Map.Entry entry : usage.tokenCounts().entrySet()) { + for (Map.Entry entry : usage.tokenCounts() + .entrySet()) { TokenType type = entry.getKey(); Long count = entry.getValue(); if (count > 0) { BigDecimal rate = plan.getRate(type); - BigDecimal typeCost = rate.multiply(BigDecimal.valueOf(count)) - .divide(THOUSAND, 10, RoundingMode.HALF_UP); + BigDecimal typeCost = rate + .multiply(BigDecimal.valueOf(count)) + .movePointLeft(3); totalCostValue = totalCostValue.add(typeCost); } } diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostTestCalculatorTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostCalculatorTest.java similarity index 86% rename from token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostTestCalculatorTest.java rename to token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostCalculatorTest.java index 7272fee..5c359c7 100644 --- a/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostTestCalculatorTest.java +++ b/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostCalculatorTest.java @@ -14,7 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat; -class DefaultCostTestCalculatorTest { +class DefaultCostCalculatorTest { private final DefaultCostCalculator calculator = new DefaultCostCalculator(); @@ -77,4 +77,19 @@ void calculateReasoningTokensWithFallback() { // Then: 1000 * 0.01 / 1000 + 1000 * 0.03 / 1000 = 0.04 assertThat(cost.amount()).isEqualByComparingTo("0.040000"); } + + @Test + @DisplayName("1,000 token은 1K 단가 그대로 계산되어야 한다") + void shouldUseRateAsCostForOneThousandTokens() { + PricingPlan plan = new PricingPlan( + "tiny-model", + new BigDecimal("0.0004"), + BigDecimal.ZERO + ); + TokenUsage usage = TokenUsage.from(1, 0); + + Cost cost = calculator.calculate(usage, plan); + + assertThat(cost.amount()).isEqualByComparingTo("0.0000004"); + } } From e29fb619529f974246799b8213f425c275bd4746 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 23 Jul 2026 15:19:00 +0900 Subject: [PATCH 05/13] feat(core): add explicit cost boundary rounding policy --- .../io/tokenledger/core/RoundingPolicy.java | 25 ++++++++++++ .../tokenledger/core/RoundingPolicyTest.java | 39 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 token-ledger-core/src/main/java/io/tokenledger/core/RoundingPolicy.java create mode 100644 token-ledger-core/src/test/java/io/tokenledger/core/RoundingPolicyTest.java diff --git a/token-ledger-core/src/main/java/io/tokenledger/core/RoundingPolicy.java b/token-ledger-core/src/main/java/io/tokenledger/core/RoundingPolicy.java new file mode 100644 index 0000000..cfa6ccd --- /dev/null +++ b/token-ledger-core/src/main/java/io/tokenledger/core/RoundingPolicy.java @@ -0,0 +1,25 @@ +package io.tokenledger.core; + +import io.tokenledger.core.domain.Cost; +import java.math.RoundingMode; + +public final class RoundingPolicy { + + public static final RoundingPolicy COST_BOUNDARY_ROUNDING = + new RoundingPolicy(6, RoundingMode.HALF_UP); + + private final int scale; + private final RoundingMode roundingMode; + + private RoundingPolicy(int scale, RoundingMode roundingMode) { + this.scale = scale; + this.roundingMode = roundingMode; + } + + public Cost apply(Cost cost) { + return Cost.of( + cost.amount().setScale(scale, roundingMode), + cost.currency() + ); + } +} \ No newline at end of file diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/RoundingPolicyTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/RoundingPolicyTest.java new file mode 100644 index 0000000..3e64b9b --- /dev/null +++ b/token-ledger-core/src/test/java/io/tokenledger/core/RoundingPolicyTest.java @@ -0,0 +1,39 @@ +package io.tokenledger.core; + +import io.tokenledger.core.domain.Cost; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Currency; + +import static org.assertj.core.api.Assertions.assertThat; + +class RoundingPolicyTest { + + private static final Currency USD = Currency.getInstance("USD"); + + @Test + @DisplayName("비용 경계 반올림은 scale 6, HALF_UP을 적용한다") + void shouldRoundCostWithScaleSixAndHalfUp() { + Cost cost = Cost.of(new BigDecimal("0.0000005"), USD); + + Cost roundedCost = RoundingPolicy.COST_BOUNDARY_ROUNDING.apply(cost); + + assertThat(roundedCost.amount()).isEqualByComparingTo("0.000001"); + assertThat(roundedCost.amount().scale()).isEqualTo(6); + assertThat(roundedCost.currency()).isEqualTo(USD); + } + + @Test + @DisplayName("비용 경계 반올림은 통화를 보존한다") + void shouldPreserveCurrencyWhenRoundingCost() { + Currency krw = Currency.getInstance("KRW"); + Cost cost = Cost.of(new BigDecimal("12.3456789"), krw); + + Cost roundedCost = RoundingPolicy.COST_BOUNDARY_ROUNDING.apply(cost); + + assertThat(roundedCost.amount()).isEqualByComparingTo("12.345679"); + assertThat(roundedCost.currency()).isEqualTo(krw); + } +} \ No newline at end of file From 3cbccd1be9e93dcf9be509a7ad6e729c74c0989c Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 23 Jul 2026 18:57:58 +0900 Subject: [PATCH 06/13] feat(budget): migrate budget amounts to Cost --- .../io/tokenledger/budget/BudgetDecision.java | 13 +- .../tokenledger/budget/BudgetEvaluator.java | 27 ++- .../io/tokenledger/budget/BudgetState.java | 3 +- .../tokenledger/budget/BudgetStateStore.java | 6 +- .../internal/DefaultBudgetEvaluator.java | 113 ++++++----- .../internal/InMemoryBudgetStateStore.java | 31 +-- .../internal/LedgerBudgetComponents.java | 3 +- .../internal/DefaultBudgetEvaluatorTest.java | 185 ++++++++++-------- .../java/io/tokenledger/core/domain/Cost.java | 7 + .../io/tokenledger/core/domain/CostTest.java | 40 ++++ 10 files changed, 245 insertions(+), 183 deletions(-) diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetDecision.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetDecision.java index 9b35981..2a4c858 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetDecision.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetDecision.java @@ -1,18 +1,17 @@ package io.tokenledger.budget; -import java.math.BigDecimal; +import io.tokenledger.core.domain.Cost; /** * 예산 평가 결과를 나타내는 값 객체입니다. *

- * 호출 가능 여부와 - * 판단에 필요한 최소한의 정보를 담습니다. + * 호출 가능 여부와 판단에 필요한 최소한의 정보를 담습니다. */ public record BudgetDecision( - BudgetState state, - String reason, - BigDecimal currentUsage, - BigDecimal limit + BudgetState state, + String reason, + Cost currentUsage, + Cost limit ) {} diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetEvaluator.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetEvaluator.java index 3de85cc..59a91c2 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetEvaluator.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetEvaluator.java @@ -1,5 +1,6 @@ package io.tokenledger.budget; +import io.tokenledger.core.domain.Cost; import java.math.BigDecimal; import java.util.Map; @@ -7,22 +8,20 @@ /** * AI 호출 전 예산 초과 여부를 판단하는 인터페이스입니다. *

- * 구현체는 현재까지 누적된 비용과 - * 이번 호출로 발생할 비용을 기준으로 - * 호출을 허용하거나 차단하는 역할을 합니다. + * 구현체는 현재까지 누적된 비용과 이번 호출로 발생할 비용을 기준으로 호출을 허용하거나 차단하는 역할을 합니다. */ public interface BudgetEvaluator { - /** - * 단순히 현재의 누적 비용이 예산 한도를 초과했는지만 판단합니다. (부수 효과 없음) - */ - BudgetDecision evaluate(Map tags); + /** + * 단순히 현재의 누적 비용이 예산 한도를 초과했는지만 판단합니다. (부수 효과 없음) + */ + BudgetDecision evaluate(Map tags); - /** - * 이번 호출로 발생할 예상 비용을 포함하여 예산 초과 여부를 판단합니다. (부수 효과 없음) - */ - BudgetDecision evaluate( - Map tags, - BigDecimal costAmount - ); + /** + * 이번 호출로 발생할 예상 비용을 포함하여 예산 초과 여부를 판단합니다. (부수 효과 없음) + */ + BudgetDecision evaluate( + Map tags, + Cost cost + ); } diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetState.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetState.java index 5f62417..9cea19a 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetState.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetState.java @@ -10,5 +10,6 @@ public enum BudgetState { ALLOW, WARN, - BLOCK + BLOCK, + CURRENCY_MISMATCH } diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java index 31c1d3b..856908f 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java @@ -1,6 +1,8 @@ package io.tokenledger.budget; +import io.tokenledger.core.domain.Cost; import java.math.BigDecimal; +import java.util.Currency; import java.util.Map; @@ -13,7 +15,7 @@ public interface BudgetStateStore { - BigDecimal getAccumulatedCost(Map tags); + Cost getAccumulatedCost(Map tags, Currency currency); - void addCost(Map tags, BigDecimal amount); + void addCost(Map tags, Cost cost); } diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java index c19e0e6..2bc9a8b 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java @@ -3,89 +3,88 @@ import io.tokenledger.budget.*; import io.tokenledger.budget.exception.BudgetExceededException; +import io.tokenledger.core.domain.Cost; import java.math.BigDecimal; import java.util.Map; /** * BudgetEvaluator의 기본 구현체입니다. - * - * 판단 기준: - * - 80% 미만 → ALLOW - * - 80% 이상 → WARN - * - 100% 이상 → BLOCK (예외 발생) - * - * 이 클래스의 evaluate 메서드는 부수 효과가 없는 순수 함수로 동작합니다. - * 실제 비용 누적은 BudgetStateStore.addCost를 통해 별도로 수행해야 합니다. + *

+ * 판단 기준: - 80% 미만 → ALLOW - 80% 이상 → WARN - 100% 이상 → BLOCK (예외 발생) - currency 이 클래스의 evaluate 메서드는 부수 효과가 없는 순수 함수로 + * 동작합니다. 실제 비용 누적은 BudgetStateStore.addCost를 통해 별도로 수행해야 합니다. */ public class DefaultBudgetEvaluator implements BudgetEvaluator { - private final BudgetStateStore store; - private final BigDecimal monthlyLimit; + private static final BigDecimal WARN_THRESHOLD_RATE = new BigDecimal("0.8"); - public DefaultBudgetEvaluator( - BudgetStateStore store, - BigDecimal monthlyLimit - ) { - this.store = store; - this.monthlyLimit = monthlyLimit; - } + private final BudgetStateStore store; + private final Cost monthlyLimit; - @Override - public BudgetDecision evaluate(Map tags) { - return evaluate(tags, BigDecimal.ZERO); - } + public DefaultBudgetEvaluator( + BudgetStateStore store, + Cost monthlyLimit + ) { + this.store = store; + this.monthlyLimit = monthlyLimit; + } - @Override - public BudgetDecision evaluate( - Map tags, - BigDecimal costAmount - ) { + @Override + public BudgetDecision evaluate(Map tags) { + return evaluate(tags, Cost.zero(monthlyLimit.currency())); + } - // ✅ 현재까지 누적 비용 - BigDecimal accumulated = store.getAccumulatedCost(tags); + @Override + public BudgetDecision evaluate( + Map tags, + Cost cost + ) { - // ✅ 이번 호출까지 포함한 비용 (비교용) - BigDecimal nextUsage = accumulated.add(costAmount); + // ✅ 현재까지 누적 비용 + Cost accumulated = store.getAccumulatedCost(tags, monthlyLimit.currency()); + // ✅ 이번 호출까지 포함한 비용 (비교용) + Cost nextUsage = accumulated.add(cost); - // ✅ 경고 기준 (80%) - BigDecimal warnThreshold = - monthlyLimit.multiply(new BigDecimal("0.8")); + // ✅ 경고 기준 (80%) + Cost warnThreshold = Cost.of( + monthlyLimit.amount().multiply(WARN_THRESHOLD_RATE), + monthlyLimit.currency() + ); /* ===================== 1️⃣ 차단 (BLOCK) ===================== */ - if (nextUsage.compareTo(monthlyLimit) >= 0) { + if (nextUsage.compareTo(monthlyLimit) >= 0) { - BudgetDecision decision = new BudgetDecision( - BudgetState.BLOCK, - "월 예산 초과로 AI 호출이 차단되었습니다", - nextUsage, - monthlyLimit - ); + BudgetDecision decision = new BudgetDecision( + BudgetState.BLOCK, + "월 예산 초과로 AI 호출이 차단되었습니다", + nextUsage, + monthlyLimit + ); - throw new BudgetExceededException(decision); - } + throw new BudgetExceededException(decision); + } /* ===================== 2️⃣ 경고 (WARN) ===================== */ - if (nextUsage.compareTo(warnThreshold) >= 0) { - return new BudgetDecision( - BudgetState.WARN, - "월 예산의 80%에 도달했습니다", - nextUsage, - monthlyLimit - ); - } + if (nextUsage.compareTo(warnThreshold) >= 0) { + return new BudgetDecision( + BudgetState.WARN, + "월 예산의 80%에 도달했습니다", + nextUsage, + monthlyLimit + ); + } /* ===================== 3️⃣ 허용 (ALLOW) ===================== */ - return new BudgetDecision( - BudgetState.ALLOW, - "예산 범위 내입니다", - nextUsage, - monthlyLimit - ); - } + return new BudgetDecision( + BudgetState.ALLOW, + "예산 범위 내입니다", + nextUsage, + monthlyLimit + ); + } } diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/InMemoryBudgetStateStore.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/InMemoryBudgetStateStore.java index 89d323a..7c394d4 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/InMemoryBudgetStateStore.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/InMemoryBudgetStateStore.java @@ -2,35 +2,36 @@ import io.tokenledger.budget.BudgetStateStore; +import io.tokenledger.core.domain.Cost; import java.math.BigDecimal; +import java.util.Currency; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * BudgetStateStore의 인메모리 기반 구현체입니다. - * - * 예산 사용량을 메모리 내에서 누적 관리하며, - * 테스트 및 간단한 실행 환경을 위한 구현입니다. + *

+ * 예산 사용량을 메모리 내에서 누적 관리하며, 테스트 및 간단한 실행 환경을 위한 구현입니다. */ public class InMemoryBudgetStateStore implements BudgetStateStore { - private final Map store = new ConcurrentHashMap<>(); + private final Map store = new ConcurrentHashMap<>(); - private String key(Map tags) { - return tags.getOrDefault("tenant_id", "default"); - } + private String key(Map tags) { + return tags.getOrDefault("tenant_id", "default"); + } - @Override - public BigDecimal getAccumulatedCost(Map tags) { // 아직 사용 기록이 없으면 0원 - return store.getOrDefault(key(tags), BigDecimal.ZERO); - } + @Override + public Cost getAccumulatedCost(Map tags, Currency currency) { + return store.getOrDefault(key(tags), Cost.zero(currency)); + } - @Override - public void addCost(Map tags, BigDecimal amount) { // 기존 값에 amount를 더함 - store.merge(key(tags), amount, BigDecimal::add); - } + @Override + public void addCost(Map tags, Cost cost) { + store.merge(key(tags), cost, Cost::add); + } } diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/LedgerBudgetComponents.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/LedgerBudgetComponents.java index bda55b7..8e9c9c1 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/LedgerBudgetComponents.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/LedgerBudgetComponents.java @@ -2,6 +2,7 @@ import io.tokenledger.budget.BudgetEvaluator; import io.tokenledger.budget.BudgetStateStore; +import io.tokenledger.core.domain.Cost; /** * 예산 제어 컴포넌트 생성을 위한 팩토리 클래스입니다. @@ -15,7 +16,7 @@ public static BudgetStateStore inMemoryBudgetStateStore() { return new InMemoryBudgetStateStore(); } - public static BudgetEvaluator defaultBudgetEvaluator(BudgetStateStore store, java.math.BigDecimal monthlyLimit) { + public static BudgetEvaluator defaultBudgetEvaluator(BudgetStateStore store, Cost monthlyLimit) { return new DefaultBudgetEvaluator(store, monthlyLimit); } } diff --git a/token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java b/token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java index 1bcec07..53aa178 100644 --- a/token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java +++ b/token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java @@ -2,6 +2,8 @@ import io.tokenledger.budget.*; import io.tokenledger.budget.exception.BudgetExceededException; +import io.tokenledger.core.domain.Cost; +import java.util.Currency; import org.junit.jupiter.api.Test; import java.math.BigDecimal; @@ -11,90 +13,101 @@ class DefaultBudgetEvaluatorTest { - @Test - void should_return_allow_when_usage_is_below_80_percent() { - // given - BudgetStateStore store = new InMemoryBudgetStateStore(); - BudgetEvaluator evaluator = - new DefaultBudgetEvaluator(store, BigDecimal.valueOf(100)); - - Map tags = Map.of("tenant_id", "test"); - - // when - BudgetDecision decision = - evaluator.evaluate(tags, BigDecimal.valueOf(50)); - - // then - assertEquals(BudgetState.ALLOW, decision.state()); - } - - @Test - void should_return_warn_when_usage_exceeds_80_percent() { - // given - BudgetStateStore store = new InMemoryBudgetStateStore(); - BudgetEvaluator evaluator = - new DefaultBudgetEvaluator(store, BigDecimal.valueOf(100)); - - Map tags = Map.of("tenant_id", "test"); - - // when - BudgetDecision decision = - evaluator.evaluate(tags, BigDecimal.valueOf(85)); - - // then - assertEquals(BudgetState.WARN, decision.state()); - } - - @Test - void should_throw_exception_when_usage_exceeds_limit() { - // given - BudgetStateStore store = new InMemoryBudgetStateStore(); - BudgetEvaluator evaluator = - new DefaultBudgetEvaluator(store, BigDecimal.valueOf(100)); - - Map tags = Map.of("tenant_id", "test"); - - // when & then - assertThrows( - BudgetExceededException.class, - () -> evaluator.evaluate(tags, BigDecimal.valueOf(120)) - ); - } - - @Test - void should_evaluate_current_status_without_cost_amount() { - // given - BudgetStateStore store = new InMemoryBudgetStateStore(); - BudgetEvaluator evaluator = - new DefaultBudgetEvaluator(store, BigDecimal.valueOf(100)); - - Map tags = Map.of("tenant_id", "test"); - store.addCost(tags, BigDecimal.valueOf(90)); - - // when - BudgetDecision decision = evaluator.evaluate(tags); - - // then - assertEquals(BudgetState.WARN, decision.state()); - assertEquals(BigDecimal.valueOf(90), decision.currentUsage()); - } - - @Test - void evaluate_should_be_pure_function() { - // given - BudgetStateStore store = new InMemoryBudgetStateStore(); - BudgetEvaluator evaluator = - new DefaultBudgetEvaluator(store, BigDecimal.valueOf(100)); - - Map tags = Map.of("tenant_id", "test"); - - // when - evaluator.evaluate(tags, BigDecimal.valueOf(50)); - evaluator.evaluate(tags, BigDecimal.valueOf(50)); - - // then - // Still ALLOW because addCost was not called - assertEquals(BigDecimal.ZERO, store.getAccumulatedCost(tags)); - assertEquals(BudgetState.ALLOW, evaluator.evaluate(tags, BigDecimal.valueOf(50)).state()); - } + private static final Currency USD = Currency.getInstance("USD"); + + private static Cost usd(String amount) { + return Cost.of(new BigDecimal(amount), USD); + } + + @Test + void should_return_allow_when_usage_is_below_80_percent() { + // given + BudgetStateStore store = new InMemoryBudgetStateStore(); + BudgetEvaluator evaluator = + new DefaultBudgetEvaluator(store, usd("100")); + + Map tags = Map.of("tenant_id", "test"); + + // when + BudgetDecision decision = + evaluator.evaluate(tags, usd("50")); + + // then + assertEquals(BudgetState.ALLOW, decision.state()); + assertEquals(usd("50"), decision.currentUsage()); + assertEquals(usd("100"), decision.limit()); + } + + @Test + void should_return_warn_when_usage_exceeds_80_percent() { + // given + BudgetStateStore store = new InMemoryBudgetStateStore(); + BudgetEvaluator evaluator = + new DefaultBudgetEvaluator(store, usd("100")); + + Map tags = Map.of("tenant_id", "test"); + + // when + BudgetDecision decision = + evaluator.evaluate(tags, usd("85")); + + // then + assertEquals(BudgetState.WARN, decision.state()); + assertEquals(usd("85"), decision.currentUsage()); + assertEquals(usd("100"), decision.limit()); + } + + @Test + void should_throw_exception_when_usage_exceeds_limit() { + // given + BudgetStateStore store = new InMemoryBudgetStateStore(); + BudgetEvaluator evaluator = + new DefaultBudgetEvaluator(store, usd("100")); + + Map tags = Map.of("tenant_id", "test"); + + // when & then + assertThrows( + BudgetExceededException.class, + () -> evaluator.evaluate(tags, usd("120")) + ); + } + + @Test + void should_evaluate_current_status_without_cost_amount() { + // given + BudgetStateStore store = new InMemoryBudgetStateStore(); + BudgetEvaluator evaluator = + new DefaultBudgetEvaluator(store, usd("100")); + + Map tags = Map.of("tenant_id", "test"); + store.addCost(tags, usd("90")); + + // when + BudgetDecision decision = evaluator.evaluate(tags); + + // then + assertEquals(BudgetState.WARN, decision.state()); + assertEquals(usd("90"), decision.currentUsage()); + assertEquals(usd("100"), decision.limit()); + } + + @Test + void evaluate_should_be_pure_function() { + // given + BudgetStateStore store = new InMemoryBudgetStateStore(); + BudgetEvaluator evaluator = + new DefaultBudgetEvaluator(store, usd("100")); + + Map tags = Map.of("tenant_id", "test"); + + // when + evaluator.evaluate(tags, usd("50")); + evaluator.evaluate(tags, usd("50")); + + // then + // Still ALLOW because addCost was not called + assertEquals(Cost.zero(USD), store.getAccumulatedCost(tags, USD)); + assertEquals(BudgetState.ALLOW, evaluator.evaluate(tags, usd("50")).state()); + } } diff --git a/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java b/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java index f6eee6d..c0bbf9b 100644 --- a/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java +++ b/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java @@ -46,6 +46,13 @@ public Cost add(Cost other) { return new Cost(totalAmount, currency); } + public int compareTo(Cost other) { + Objects.requireNonNull(other, "cost cant be null"); + validateSameCurrency(other); + + return amount.compareTo(other.amount); + } + private void validateSameCurrency(Cost other) { if (!currency.equals(other.currency)) { throw new IllegalArgumentException("Cannot operate on costs with different currencies"); diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java index 08e6785..37e568e 100644 --- a/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java +++ b/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java @@ -44,6 +44,15 @@ void shouldPreserveCurrencyWhenCreatingZeroCost() { assertThat(cost.currency()).isEqualTo(KRW); } + @Test + @DisplayName("null 비용을 추가하면 거부해야 한다.") + void shouldRejectAddWithNullCost() { + Cost cost = Cost.of(BigDecimal.ONE, USD); + + assertThatNullPointerException() + .isThrownBy(() -> cost.compareTo(null)); + } + @Test @DisplayName("동일한 통화로 비용을 추가해야 한다.") void shouldAddCostsWithSameCurrency() { @@ -66,6 +75,37 @@ void shouldRejectAddWithDifferentCurrency() { .isInstanceOf(IllegalArgumentException.class); } + @Test + @DisplayName("null 비용과 비교하면 거부해야 한다.") + void shouldRejectCompareWithNullCost() { + Cost cost = Cost.of(BigDecimal.ONE, USD); + + assertThatNullPointerException() + .isThrownBy(() -> cost.compareTo(null)); + } + + @Test + @DisplayName("동일한 통화로 비용을 비교해야 한다.") + void shouldCompareCostsWithSameCurrency() { + Cost lower = Cost.of(new BigDecimal("1.25"), USD); + Cost higher = Cost.of(new BigDecimal("2.75"), USD); + Cost sameAmount = Cost.of(new BigDecimal("1.250"), USD); + + assertThat(lower.compareTo(higher)).isNegative(); + assertThat(higher.compareTo(lower)).isPositive(); + assertThat(lower.compareTo(sameAmount)).isZero(); + } + + @Test + @DisplayName("다른 통화로 비용을 비교하면 거부해야 한다.") + void shouldRejectCompareWithDifferentCurrency() { + Cost usdCost = Cost.of(BigDecimal.ONE, USD); + Cost krwCost = Cost.of(BigDecimal.ONE, KRW); + + assertThatThrownBy(() -> usdCost.compareTo(krwCost)) + .isInstanceOf(IllegalArgumentException.class); + } + @Test @DisplayName("수치적으로 동일한 금액은 동일하게 취급해야 한다.") void shouldTreatNumericallyEqualAmountsAsEqualRegardlessOfScale() { From 671333085f35c7e770cfac9fb3e49633a2dee62f Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 23 Jul 2026 20:02:42 +0900 Subject: [PATCH 07/13] fix(budget): reject currency mismatches during evaluation --- .../internal/DefaultBudgetEvaluator.java | 18 ++++++++++ .../internal/DefaultBudgetEvaluatorTest.java | 33 ++++++++++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java index 2bc9a8b..96507ff 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java @@ -41,6 +41,11 @@ public BudgetDecision evaluate( // ✅ 현재까지 누적 비용 Cost accumulated = store.getAccumulatedCost(tags, monthlyLimit.currency()); + + if (hasCurrencyMismatch(cost)) { + return currencyMismatchDecision(accumulated); + } + // ✅ 이번 호출까지 포함한 비용 (비교용) Cost nextUsage = accumulated.add(cost); @@ -87,4 +92,17 @@ public BudgetDecision evaluate( monthlyLimit ); } + + private boolean hasCurrencyMismatch(Cost cost) { + return !monthlyLimit.currency().equals(cost.currency()); + } + + private BudgetDecision currencyMismatchDecision(Cost currentUsage) { + return new BudgetDecision( + BudgetState.CURRENCY_MISMATCH, + "예산 통화와 비용 통화가 일치하지 않습니다", + currentUsage, + monthlyLimit + ); + } } diff --git a/token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java b/token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java index 53aa178..6a0febc 100644 --- a/token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java +++ b/token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java @@ -14,13 +14,18 @@ class DefaultBudgetEvaluatorTest { private static final Currency USD = Currency.getInstance("USD"); + private static final Currency KRW = Currency.getInstance("KRW"); private static Cost usd(String amount) { return Cost.of(new BigDecimal(amount), USD); } + private static Cost krw(String amount) { + return Cost.of(new BigDecimal(amount), KRW); + } + @Test - void should_return_allow_when_usage_is_below_80_percent() { + void shouldReturnAllowWhenUsageIsBelow80Percent() { // given BudgetStateStore store = new InMemoryBudgetStateStore(); BudgetEvaluator evaluator = @@ -39,7 +44,7 @@ void should_return_allow_when_usage_is_below_80_percent() { } @Test - void should_return_warn_when_usage_exceeds_80_percent() { + void shouldReturnWarnWhenUsageExceeds80Percent() { // given BudgetStateStore store = new InMemoryBudgetStateStore(); BudgetEvaluator evaluator = @@ -58,7 +63,7 @@ void should_return_warn_when_usage_exceeds_80_percent() { } @Test - void should_throw_exception_when_usage_exceeds_limit() { + void shouldThrowExceptionWhenUsageExceedsLimit() { // given BudgetStateStore store = new InMemoryBudgetStateStore(); BudgetEvaluator evaluator = @@ -74,7 +79,7 @@ void should_throw_exception_when_usage_exceeds_limit() { } @Test - void should_evaluate_current_status_without_cost_amount() { + void shouldEvaluateCurrentStatusWithoutCostAmount() { // given BudgetStateStore store = new InMemoryBudgetStateStore(); BudgetEvaluator evaluator = @@ -93,7 +98,7 @@ void should_evaluate_current_status_without_cost_amount() { } @Test - void evaluate_should_be_pure_function() { + void evaluateShouldBePureFunction() { // given BudgetStateStore store = new InMemoryBudgetStateStore(); BudgetEvaluator evaluator = @@ -110,4 +115,22 @@ void evaluate_should_be_pure_function() { assertEquals(Cost.zero(USD), store.getAccumulatedCost(tags, USD)); assertEquals(BudgetState.ALLOW, evaluator.evaluate(tags, usd("50")).state()); } + + @Test + void shouldReturnCurrencyMismatchWithoutChangingAccumulatedCost() { + BudgetStateStore store = new InMemoryBudgetStateStore(); + BudgetEvaluator evaluator = + new DefaultBudgetEvaluator(store, usd("100")); + + Map tags = Map.of("tenant_id", "test"); + store.addCost(tags, usd("10")); + Cost accumulatedBefore = store.getAccumulatedCost(tags, USD); + + BudgetDecision decision = evaluator.evaluate(tags, krw("50")); + + assertEquals(BudgetState.CURRENCY_MISMATCH, decision.state()); + assertEquals(accumulatedBefore, decision.currentUsage()); + assertEquals(usd("100"), decision.limit()); + assertEquals(accumulatedBefore, store.getAccumulatedCost(tags, USD)); + } } From 4cffe62e90cff771d4b11f9f6e5afe5c2c949bde Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 23 Jul 2026 20:22:07 +0900 Subject: [PATCH 08/13] chore(budget): remove unused BigDecimal imports --- .../src/main/java/io/tokenledger/budget/BudgetEvaluator.java | 1 - .../src/main/java/io/tokenledger/budget/BudgetStateStore.java | 1 - 2 files changed, 2 deletions(-) diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetEvaluator.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetEvaluator.java index 59a91c2..812c77e 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetEvaluator.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetEvaluator.java @@ -1,7 +1,6 @@ package io.tokenledger.budget; import io.tokenledger.core.domain.Cost; -import java.math.BigDecimal; import java.util.Map; diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java index 856908f..c3c2d5a 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java @@ -1,7 +1,6 @@ package io.tokenledger.budget; import io.tokenledger.core.domain.Cost; -import java.math.BigDecimal; import java.util.Currency; import java.util.Map; From 3ffaa8124b7a40f45078438d88ec96d65a585dc0 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 23 Jul 2026 20:22:39 +0900 Subject: [PATCH 09/13] test(core): cover boundary rounding after small cost accumulation --- .../tokenledger/core/RoundingPolicyTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/RoundingPolicyTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/RoundingPolicyTest.java index 3e64b9b..46c83a3 100644 --- a/token-ledger-core/src/test/java/io/tokenledger/core/RoundingPolicyTest.java +++ b/token-ledger-core/src/test/java/io/tokenledger/core/RoundingPolicyTest.java @@ -36,4 +36,22 @@ void shouldPreserveCurrencyWhenRoundingCost() { assertThat(roundedCost.amount()).isEqualByComparingTo("12.345679"); assertThat(roundedCost.currency()).isEqualTo(krw); } + + @Test + @DisplayName("작은 비용을 여러 번 더한 값은 최종 한 번 반올림한 값과 같아야 한다") + void shouldRoundAccumulatedSmallCostsOnceAtBoundary() { + Cost unitCost = Cost.of(new BigDecimal("0.0000004"), USD); + Cost accumulated = Cost.zero(USD); + + for (int i = 0; i < 1000; i++) { + accumulated = accumulated.add(unitCost); + } + + Cost rounded = RoundingPolicy.COST_BOUNDARY_ROUNDING.apply(accumulated); + + assertThat(accumulated.amount()).isEqualByComparingTo("0.0004000"); + assertThat(rounded.amount()).isEqualByComparingTo("0.000400"); + assertThat(rounded.amount().scale()).isEqualTo(6); + assertThat(rounded.currency()).isEqualTo(USD); + } } \ No newline at end of file From cedcf596e52b80979c317722eee84ce77a88d47a Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 23 Jul 2026 20:23:02 +0900 Subject: [PATCH 10/13] docs: clarify cost precision and boundary rounding --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7937560..0cf2085 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Spring AI 애플리케이션에서 AI 호출 토큰 사용량과 비용을 기 - 모델별 가격 정책: `PricingPlan` - 토큰 타입별 사용량: `PROMPT`, `COMPLETION`, `REASONING`, `CACHED_PROMPT`, `CACHED_COMPLETION` -- 비용 계산: 1K 토큰당 단가 기반, 최종 비용 소수점 6자리 반올림 +- 비용 계산: 1K 토큰당 단가 기반으로 내부 정밀도를 보존하고, 외부 경계에서 `RoundingPolicy`로 명시적으로 반올림 - 비용 기록 이벤트: `CostRecordedEvent` - 리스너 기반 확장: `LedgerListener` - Spring AI 응답 usage 추출: `UsageExtractor` From 7b74887d8308da10606a34afab8f398db579b152 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 23 Jul 2026 21:17:48 +0900 Subject: [PATCH 11/13] docs(core): update Cost record parameter docs --- .../src/main/java/io/tokenledger/core/domain/Cost.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java b/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java index c0bbf9b..34bf65d 100644 --- a/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java +++ b/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java @@ -8,8 +8,8 @@ /** * 계산된 AI 호출 비용 정보. * - * @param amount 비용 (BigDecimal, 소수점 6자리 권장) - * @param currency 통화 (기본값: USD) + * @param amount 비용 (BigDecimal) + * @param currency 통화 (Currency) */ public record Cost( BigDecimal amount, From 0f4c515dda5fada13de7e299a0133b144cd060b1 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 23 Jul 2026 21:18:24 +0900 Subject: [PATCH 12/13] test(core): verify Cost add rejects null operand --- .../src/test/java/io/tokenledger/core/domain/CostTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java index 37e568e..0596dcf 100644 --- a/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java +++ b/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java @@ -50,7 +50,7 @@ void shouldRejectAddWithNullCost() { Cost cost = Cost.of(BigDecimal.ONE, USD); assertThatNullPointerException() - .isThrownBy(() -> cost.compareTo(null)); + .isThrownBy(() -> cost.add(null)); } @Test From 5cd6d8552beafca4ce73aa8d32e241bba2bf45b3 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Thu, 23 Jul 2026 21:18:50 +0900 Subject: [PATCH 13/13] docs(budget): document currency mismatch decision criteria --- .../budget/internal/DefaultBudgetEvaluator.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java index 96507ff..fb925a0 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java @@ -10,8 +10,14 @@ /** * BudgetEvaluator의 기본 구현체입니다. *

- * 판단 기준: - 80% 미만 → ALLOW - 80% 이상 → WARN - 100% 이상 → BLOCK (예외 발생) - currency 이 클래스의 evaluate 메서드는 부수 효과가 없는 순수 함수로 - * 동작합니다. 실제 비용 누적은 BudgetStateStore.addCost를 통해 별도로 수행해야 합니다. + * 판단 기준: + * - 80% 미만 → ALLOW + * - 80% 이상 → WARN + * - 100% 이상 → BLOCK (예외 발생) + * - 통화 불일치 → CURRENCY_MISMATCH + *

+ * 이 클래스의 evaluate 메서드는 부수 효과가 없는 순수 함수로 동작합니다. + * 실제 비용 누적은 BudgetStateStore.addCost를 통해 별도로 수행해야 합니다. */ public class DefaultBudgetEvaluator implements BudgetEvaluator { @@ -30,7 +36,7 @@ public DefaultBudgetEvaluator( @Override public BudgetDecision evaluate(Map tags) { - return evaluate(tags, Cost.zero(monthlyLimit.currency())); + return evaluate(tags, Cost.zero(monthlyLimit .currency())); } @Override @@ -42,7 +48,7 @@ public BudgetDecision evaluate( // ✅ 현재까지 누적 비용 Cost accumulated = store.getAccumulatedCost(tags, monthlyLimit.currency()); - if (hasCurrencyMismatch(cost)) { + if (hasCurrencyMismatch(accumulated) || hasCurrencyMismatch(cost)) { return currencyMismatchDecision(accumulated); }