From 164aa4ef16ce2f811aaf67713d70675185409d55 Mon Sep 17 00:00:00 2001
From: rigu1
Date: Tue, 18 Aug 2026 02:29:38 +0900
Subject: [PATCH 01/13] =?UTF-8?q?feat(budget):=20=ED=9A=8C=EA=B3=84=20?=
=?UTF-8?q?=EC=83=9D=EB=AA=85=EC=A3=BC=EA=B8=B0=20=EC=83=81=ED=83=9C?=
=?UTF-8?q?=EC=99=80=20=EC=A0=84=EC=9D=B4=20=EA=B2=B0=EA=B3=BC=20=EC=A0=95?=
=?UTF-8?q?=EC=9D=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../budget/AccountingTransitionStatus.java | 36 +++++++++++++++++++
.../tokenpilot/budget/ReservationState.java | 29 +++++++++++++--
.../AccountingTransitionStatusTest.java | 25 +++++++++++++
.../budget/ReservationStateTest.java | 25 +++++++++++++
4 files changed, 112 insertions(+), 3 deletions(-)
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/AccountingTransitionStatus.java
create mode 100644 token-pilot-budget/src/test/java/io/tokenpilot/budget/AccountingTransitionStatusTest.java
create mode 100644 token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateTest.java
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/AccountingTransitionStatus.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/AccountingTransitionStatus.java
new file mode 100644
index 0000000..0ea8f2c
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/AccountingTransitionStatus.java
@@ -0,0 +1,36 @@
+package io.tokenpilot.budget;
+
+/**
+ * 예약 생성 이후 회계 상태 변경 명령의 결과입니다.
+ *
+ * {@link #APPLIED}만 새로운 회계 변경이 적용되었음을 뜻합니다. 나머지 결과는 기존 상태와
+ * 금액을 바꾸지 않았음을 뜻하므로 호출자는 예외 메시지나 저장소 내부 구현에 의존하지 않고
+ * 재시도, 충돌 처리, 입력 보정을 결정할 수 있습니다.
+ */
+public enum AccountingTransitionStatus {
+ /** 요청한 회계 변경이 새로 적용되었습니다. */
+ APPLIED,
+
+ /** 같은 명령이 이미 적용되어 기존 결과가 재사용되었습니다. */
+ REUSED,
+
+ /** 같은 멱등성 식별자에 서로 다른 명령 내용이 사용되었습니다. */
+ CONFLICT,
+
+ /** 대상 예약을 찾을 수 없습니다. */
+ NOT_FOUND,
+
+ /** 명령 금액의 통화가 예약 통화와 다릅니다. */
+ CURRENCY_MISMATCH,
+
+ /** 명령 인자가 계약을 만족하지 않습니다. */
+ INVALID_ARGUMENT,
+
+ /** 현재 예약 상태에서는 요청한 전이가 허용되지 않습니다. */
+ NOT_ALLOWED;
+
+ /** 새로운 회계 변경이 적용되었는지 반환합니다. */
+ public boolean isApplied() {
+ return this == APPLIED;
+ }
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationState.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationState.java
index 3a1a2cf..4a1cd07 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationState.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationState.java
@@ -2,9 +2,32 @@
/**
* 예산 예약의 현재 회계 상태입니다.
- *
- * commit/release/reconciliation 상태 전이는 #37에서 확장합니다.
*/
public enum ReservationState {
- RESERVED
+ /** 안전 상한액이 예약되었지만 공급자 호출은 시작되지 않은 상태입니다. */
+ RESERVED,
+
+ /** 예약을 사용해 공급자 호출이 진행 중인 상태입니다. */
+ IN_FLIGHT,
+
+ /** 실제 사용량을 알 수 없어 후속 정산이 필요한 상태입니다. */
+ RECONCILIATION_REQUIRED,
+
+ /** 실제 사용 금액이 확정된 종료 상태입니다. */
+ COMMITTED,
+
+ /** 사용되지 않은 예약 금액이 해제된 종료 상태입니다. */
+ RELEASED,
+
+ /** 실제 사용량을 끝내 확정할 수 없어 정책에 따라 상각된 종료 상태입니다. */
+ WRITTEN_OFF;
+
+ /**
+ * 더 이상 정상적인 회계 전이를 허용하지 않는 종료 상태인지 반환합니다.
+ *
+ * {@link #RECONCILIATION_REQUIRED}는 후속 정산을 기다리는 상태이므로 종료 상태가 아닙니다.
+ */
+ public boolean isClosed() {
+ return this == COMMITTED || this == RELEASED || this == WRITTEN_OFF;
+ }
}
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/AccountingTransitionStatusTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/AccountingTransitionStatusTest.java
new file mode 100644
index 0000000..352b91a
--- /dev/null
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/AccountingTransitionStatusTest.java
@@ -0,0 +1,25 @@
+package io.tokenpilot.budget;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AccountingTransitionStatusTest {
+
+ @Test
+ @DisplayName("APPLIED만 새로운 회계 변경을 뜻한다")
+ void onlyAppliedMeansANewAccountingChange() {
+ assertThat(AccountingTransitionStatus.APPLIED.isApplied()).isTrue();
+ }
+
+ @Test
+ @DisplayName("APPLIED 외의 결과는 회계 상태를 변경하지 않는다")
+ void statusesOtherThanAppliedDoNotChangeAccountingState() {
+ assertThat(Arrays.stream(AccountingTransitionStatus.values())
+ .filter(status -> status != AccountingTransitionStatus.APPLIED))
+ .allMatch(status -> !status.isApplied());
+ }
+}
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateTest.java
new file mode 100644
index 0000000..886da95
--- /dev/null
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateTest.java
@@ -0,0 +1,25 @@
+package io.tokenpilot.budget;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ReservationStateTest {
+
+ @Test
+ @DisplayName("정산 대기 상태는 종료 상태가 아니다")
+ void pendingReconciliationStatesAreNotClosed() {
+ assertThat(ReservationState.RESERVED.isClosed()).isFalse();
+ assertThat(ReservationState.IN_FLIGHT.isClosed()).isFalse();
+ assertThat(ReservationState.RECONCILIATION_REQUIRED.isClosed()).isFalse();
+ }
+
+ @Test
+ @DisplayName("확정, 해제, 상각 상태만 종료 상태다")
+ void committedReleasedAndWrittenOffStatesAreClosed() {
+ assertThat(ReservationState.COMMITTED.isClosed()).isTrue();
+ assertThat(ReservationState.RELEASED.isClosed()).isTrue();
+ assertThat(ReservationState.WRITTEN_OFF.isClosed()).isTrue();
+ }
+}
From 44acc5991dc2d18b259b9041021a98fc0ae6dda1 Mon Sep 17 00:00:00 2001
From: rigu1
Date: Wed, 19 Aug 2026 15:32:54 +0900
Subject: [PATCH 02/13] =?UTF-8?q?feat(budget):=20=EC=98=88=EC=95=BD=20?=
=?UTF-8?q?=ED=9A=8C=EA=B3=84=20=EC=83=81=ED=83=9C=20=EC=A0=84=EC=9D=B4=20?=
=?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../budget/AccountingTransitionStatus.java | 4 +-
.../budget/ReservationStateMachine.java | 83 ++++++++++++
.../budget/ReservationTransition.java | 44 +++++++
.../budget/ReservationStateMachineTest.java | 120 ++++++++++++++++++
.../budget/ReservationTransitionTest.java | 27 ++++
5 files changed, 276 insertions(+), 2 deletions(-)
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTransition.java
create mode 100644 token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java
create mode 100644 token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationTransitionTest.java
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/AccountingTransitionStatus.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/AccountingTransitionStatus.java
index 0ea8f2c..00df53c 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/AccountingTransitionStatus.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/AccountingTransitionStatus.java
@@ -11,10 +11,10 @@ public enum AccountingTransitionStatus {
/** 요청한 회계 변경이 새로 적용되었습니다. */
APPLIED,
- /** 같은 명령이 이미 적용되어 기존 결과가 재사용되었습니다. */
+ /** 같은 회계 명령과 값이 이미 적용되어 기존 결과가 재사용되었습니다. */
REUSED,
- /** 같은 멱등성 식별자에 서로 다른 명령 내용이 사용되었습니다. */
+ /** 기존에 적용된 종료 명령 또는 값이 새 요청과 충돌합니다. */
CONFLICT,
/** 대상 예약을 찾을 수 없습니다. */
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java
new file mode 100644
index 0000000..53b250a
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java
@@ -0,0 +1,83 @@
+package io.tokenpilot.budget;
+
+import java.util.Objects;
+
+/**
+ * 예약의 회계 상태 전이 가능 여부를 판단합니다.
+ */
+public final class ReservationStateMachine {
+
+ private ReservationStateMachine() {
+ }
+
+ /** 예약된 요청의 공급자 호출을 시작합니다. */
+ public static ReservationTransition onDispatch(ReservationState currentState) {
+ return transitionFrom(
+ currentState,
+ ReservationState.RESERVED,
+ ReservationState.IN_FLIGHT
+ );
+ }
+
+ /** 공급자 호출 전에 예약을 해제합니다. */
+ public static ReservationTransition release(ReservationState currentState) {
+ return transitionFrom(
+ currentState,
+ ReservationState.RESERVED,
+ ReservationState.RELEASED
+ );
+ }
+
+ /** 공급자 호출 후 actual을 알 수 있으면 비용을 확정합니다. */
+ public static ReservationTransition onActualKnown(ReservationState currentState) {
+ return transitionFrom(
+ currentState,
+ ReservationState.IN_FLIGHT,
+ ReservationState.COMMITTED
+ );
+ }
+
+ /** 공급자 호출 후 actual을 알 수 없으면 정산 대기로 전환합니다. */
+ public static ReservationTransition onActualUnavailable(ReservationState currentState) {
+ return transitionFrom(
+ currentState,
+ ReservationState.IN_FLIGHT,
+ ReservationState.RECONCILIATION_REQUIRED
+ );
+ }
+
+ /** 정산 대기 중 late actual이 도착하면 비용을 확정합니다. */
+ public static ReservationTransition onLateActual(ReservationState currentState) {
+ return transitionFrom(
+ currentState,
+ ReservationState.RECONCILIATION_REQUIRED,
+ ReservationState.COMMITTED
+ );
+ }
+
+ /** 정산 대기 중인 예약을 명시적으로 상각합니다. */
+ public static ReservationTransition writeOff(ReservationState currentState) {
+ return transitionFrom(
+ currentState,
+ ReservationState.RECONCILIATION_REQUIRED,
+ ReservationState.WRITTEN_OFF
+ );
+ }
+
+ private static ReservationTransition transitionFrom(
+ ReservationState currentState,
+ ReservationState requiredState,
+ ReservationState resultingState
+ ) {
+ Objects.requireNonNull(currentState, "currentState must not be null");
+
+ if (currentState == requiredState) {
+ return ReservationTransition.applied(currentState, resultingState);
+ }
+
+ return ReservationTransition.unchanged(
+ currentState,
+ AccountingTransitionStatus.NOT_ALLOWED
+ );
+ }
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTransition.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTransition.java
new file mode 100644
index 0000000..ed66bc0
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTransition.java
@@ -0,0 +1,44 @@
+package io.tokenpilot.budget;
+
+import java.util.Objects;
+
+/**
+ * 예약의 상태 전이 판단입니다.
+ */
+public record ReservationTransition(
+ ReservationState previousState,
+ ReservationState resultingState,
+ AccountingTransitionStatus status
+) {
+
+ public ReservationTransition {
+ Objects.requireNonNull(previousState, "previousState must not be null");
+ Objects.requireNonNull(resultingState, "resultingState must not be null");
+ Objects.requireNonNull(status, "status must not be null");
+
+ boolean stateChanged = previousState != resultingState;
+ if (status.isApplied() != stateChanged) {
+ throw new IllegalArgumentException(
+ "APPLIED must change reservation state and all other statuses must preserve it"
+ );
+ }
+ }
+
+ public static ReservationTransition applied(
+ ReservationState previousState,
+ ReservationState resultingState
+ ) {
+ return new ReservationTransition(
+ previousState,
+ resultingState,
+ AccountingTransitionStatus.APPLIED
+ );
+ }
+
+ public static ReservationTransition unchanged(
+ ReservationState state,
+ AccountingTransitionStatus status
+ ) {
+ return new ReservationTransition(state, state, status);
+ }
+}
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java
new file mode 100644
index 0000000..5b3f8d0
--- /dev/null
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java
@@ -0,0 +1,120 @@
+package io.tokenpilot.budget;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import static io.tokenpilot.budget.AccountingTransitionStatus.APPLIED;
+import static io.tokenpilot.budget.AccountingTransitionStatus.NOT_ALLOWED;
+import static io.tokenpilot.budget.ReservationState.COMMITTED;
+import static io.tokenpilot.budget.ReservationState.IN_FLIGHT;
+import static io.tokenpilot.budget.ReservationState.RECONCILIATION_REQUIRED;
+import static io.tokenpilot.budget.ReservationState.RELEASED;
+import static io.tokenpilot.budget.ReservationState.RESERVED;
+import static io.tokenpilot.budget.ReservationState.WRITTEN_OFF;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ReservationStateMachineTest {
+
+ @Test
+ @DisplayName("예약된 요청의 호출을 시작하면 진행 중 상태가 된다")
+ void movesReservedRequestInFlightOnDispatch() {
+ var transition = ReservationStateMachine.onDispatch(RESERVED);
+
+ assertThat(transition.previousState()).isEqualTo(RESERVED);
+ assertThat(transition.resultingState()).isEqualTo(IN_FLIGHT);
+ assertThat(transition.status()).isEqualTo(APPLIED);
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = ReservationState.class, names = "RESERVED", mode = EnumSource.Mode.EXCLUDE)
+ @DisplayName("예약 상태가 아닌 요청에는 호출 시작을 적용하지 않는다")
+ void rejectsDispatchUnlessReserved(ReservationState currentState) {
+ var transition = ReservationStateMachine.onDispatch(currentState);
+
+ assertThat(transition.previousState()).isEqualTo(currentState);
+ assertThat(transition.resultingState()).isEqualTo(currentState);
+ assertThat(transition.status()).isEqualTo(NOT_ALLOWED);
+ }
+
+ @Test
+ @DisplayName("호출 전에 예약을 취소하면 해제 상태가 된다")
+ void releasesReservedRequestBeforeDispatch() {
+ var transition = ReservationStateMachine.release(RESERVED);
+
+ assertThat(transition.previousState()).isEqualTo(RESERVED);
+ assertThat(transition.resultingState()).isEqualTo(RELEASED);
+ assertThat(transition.status()).isEqualTo(APPLIED);
+ }
+
+ @Test
+ @DisplayName("호출을 시작한 예약은 미과금 확인 없이 해제하지 않는다")
+ void rejectsReleaseAfterDispatch() {
+ var transition = ReservationStateMachine.release(IN_FLIGHT);
+
+ assertThat(transition.previousState()).isEqualTo(IN_FLIGHT);
+ assertThat(transition.resultingState()).isEqualTo(IN_FLIGHT);
+ assertThat(transition.status()).isEqualTo(NOT_ALLOWED);
+ }
+
+ @Test
+ @DisplayName("호출 후 actual을 알면 비용 확정 상태가 된다")
+ void commitsInFlightReservationWhenActualIsKnown() {
+ var transition = ReservationStateMachine.onActualKnown(IN_FLIGHT);
+
+ assertThat(transition.previousState()).isEqualTo(IN_FLIGHT);
+ assertThat(transition.resultingState()).isEqualTo(COMMITTED);
+ assertThat(transition.status()).isEqualTo(APPLIED);
+ }
+
+ @Test
+ @DisplayName("호출 후 actual을 알 수 없으면 정산 대기 상태가 된다")
+ void requiresReconciliationWhenActualIsUnavailable() {
+ var transition = ReservationStateMachine.onActualUnavailable(IN_FLIGHT);
+
+ assertThat(transition.previousState()).isEqualTo(IN_FLIGHT);
+ assertThat(transition.resultingState()).isEqualTo(RECONCILIATION_REQUIRED);
+ assertThat(transition.status()).isEqualTo(APPLIED);
+ }
+
+ @Test
+ @DisplayName("정산 대기 중 late actual이 도착하면 비용 확정 상태가 된다")
+ void commitsReservationWhenLateActualArrives() {
+ var transition = ReservationStateMachine.onLateActual(RECONCILIATION_REQUIRED);
+
+ assertThat(transition.previousState()).isEqualTo(RECONCILIATION_REQUIRED);
+ assertThat(transition.resultingState()).isEqualTo(COMMITTED);
+ assertThat(transition.status()).isEqualTo(APPLIED);
+ }
+
+ @Test
+ @DisplayName("정산 대기 중 write-off를 결정하면 상각 상태가 된다")
+ void writesOffReservationAwaitingReconciliation() {
+ var transition = ReservationStateMachine.writeOff(RECONCILIATION_REQUIRED);
+
+ assertThat(transition.previousState()).isEqualTo(RECONCILIATION_REQUIRED);
+ assertThat(transition.resultingState()).isEqualTo(WRITTEN_OFF);
+ assertThat(transition.status()).isEqualTo(APPLIED);
+ }
+
+ @Test
+ @DisplayName("상태 정보만으로 종료 명령 재사용을 판단하지 않는다")
+ void doesNotInferTerminalReplayFromStateAlone() {
+ var transition = ReservationStateMachine.onActualKnown(COMMITTED);
+
+ assertThat(transition.previousState()).isEqualTo(COMMITTED);
+ assertThat(transition.resultingState()).isEqualTo(COMMITTED);
+ assertThat(transition.status()).isEqualTo(NOT_ALLOWED);
+ }
+
+ @Test
+ @DisplayName("상태 정보만으로 종료 명령 충돌을 판단하지 않는다")
+ void doesNotInferTerminalConflictFromStateAlone() {
+ var transition = ReservationStateMachine.release(COMMITTED);
+
+ assertThat(transition.previousState()).isEqualTo(COMMITTED);
+ assertThat(transition.resultingState()).isEqualTo(COMMITTED);
+ assertThat(transition.status()).isEqualTo(NOT_ALLOWED);
+ }
+}
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationTransitionTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationTransitionTest.java
new file mode 100644
index 0000000..9a158eb
--- /dev/null
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationTransitionTest.java
@@ -0,0 +1,27 @@
+package io.tokenpilot.budget;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static io.tokenpilot.budget.AccountingTransitionStatus.APPLIED;
+import static io.tokenpilot.budget.AccountingTransitionStatus.NOT_ALLOWED;
+import static io.tokenpilot.budget.ReservationState.IN_FLIGHT;
+import static io.tokenpilot.budget.ReservationState.RESERVED;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ReservationTransitionTest {
+
+ @Test
+ @DisplayName("적용된 전이는 예약 상태를 변경해야 한다")
+ void rejectsAppliedTransitionWithoutStateChange() {
+ assertThatThrownBy(() -> new ReservationTransition(RESERVED, RESERVED, APPLIED))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ @DisplayName("적용되지 않은 전이는 예약 상태를 유지해야 한다")
+ void rejectsUnappliedTransitionWithStateChange() {
+ assertThatThrownBy(() -> new ReservationTransition(RESERVED, IN_FLIGHT, NOT_ALLOWED))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
From a582e1747045095b9bd993483fbfabbee5f676aa Mon Sep 17 00:00:00 2001
From: rigu1
Date: Wed, 19 Aug 2026 17:03:05 +0900
Subject: [PATCH 03/13] =?UTF-8?q?refactor(budget):=20=EB=A9=94=EC=84=9C?=
=?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EB=A6=84=20=EB=AA=85=ED=99=95=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../budget/ReservationStateMachine.java | 12 +++++------
.../budget/ReservationStateMachineTest.java | 20 +++++++++----------
2 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java
index 53b250a..d2fb37f 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java
@@ -28,8 +28,8 @@ public static ReservationTransition release(ReservationState currentState) {
);
}
- /** 공급자 호출 후 actual을 알 수 있으면 비용을 확정합니다. */
- public static ReservationTransition onActualKnown(ReservationState currentState) {
+ /** 전달받은 actual을 확정하기 위한 상태 전이를 판단합니다. */
+ public static ReservationTransition commit(ReservationState currentState) {
return transitionFrom(
currentState,
ReservationState.IN_FLIGHT,
@@ -37,8 +37,8 @@ public static ReservationTransition onActualKnown(ReservationState currentState)
);
}
- /** 공급자 호출 후 actual을 알 수 없으면 정산 대기로 전환합니다. */
- public static ReservationTransition onActualUnavailable(ReservationState currentState) {
+ /** actual을 전달받지 못한 예약을 정산 대기로 전환할 수 있는지 판단합니다. */
+ public static ReservationTransition markReconciliationRequired(ReservationState currentState) {
return transitionFrom(
currentState,
ReservationState.IN_FLIGHT,
@@ -46,8 +46,8 @@ public static ReservationTransition onActualUnavailable(ReservationState current
);
}
- /** 정산 대기 중 late actual이 도착하면 비용을 확정합니다. */
- public static ReservationTransition onLateActual(ReservationState currentState) {
+ /** 정산 대기 중 전달받은 late actual을 확정할 수 있는지 판단합니다. */
+ public static ReservationTransition reconcileLateActual(ReservationState currentState) {
return transitionFrom(
currentState,
ReservationState.RECONCILIATION_REQUIRED,
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java
index 5b3f8d0..98de361 100644
--- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java
@@ -59,9 +59,9 @@ void rejectsReleaseAfterDispatch() {
}
@Test
- @DisplayName("호출 후 actual을 알면 비용 확정 상태가 된다")
- void commitsInFlightReservationWhenActualIsKnown() {
- var transition = ReservationStateMachine.onActualKnown(IN_FLIGHT);
+ @DisplayName("호출 후 actual을 전달해 commit하면 비용이 확정된다")
+ void commitsInFlightReservationWithActual() {
+ var transition = ReservationStateMachine.commit(IN_FLIGHT);
assertThat(transition.previousState()).isEqualTo(IN_FLIGHT);
assertThat(transition.resultingState()).isEqualTo(COMMITTED);
@@ -69,9 +69,9 @@ void commitsInFlightReservationWhenActualIsKnown() {
}
@Test
- @DisplayName("호출 후 actual을 알 수 없으면 정산 대기 상태가 된다")
- void requiresReconciliationWhenActualIsUnavailable() {
- var transition = ReservationStateMachine.onActualUnavailable(IN_FLIGHT);
+ @DisplayName("actual을 확보하지 못해 정산 대기를 요청하면 정산 대기 상태가 된다")
+ void marksReconciliationRequiredWithoutActual() {
+ var transition = ReservationStateMachine.markReconciliationRequired(IN_FLIGHT);
assertThat(transition.previousState()).isEqualTo(IN_FLIGHT);
assertThat(transition.resultingState()).isEqualTo(RECONCILIATION_REQUIRED);
@@ -79,9 +79,9 @@ void requiresReconciliationWhenActualIsUnavailable() {
}
@Test
- @DisplayName("정산 대기 중 late actual이 도착하면 비용 확정 상태가 된다")
- void commitsReservationWhenLateActualArrives() {
- var transition = ReservationStateMachine.onLateActual(RECONCILIATION_REQUIRED);
+ @DisplayName("late actual을 전달해 reconcile하면 비용이 확정된다")
+ void reconcilesReservationWithLateActual() {
+ var transition = ReservationStateMachine.reconcileLateActual(RECONCILIATION_REQUIRED);
assertThat(transition.previousState()).isEqualTo(RECONCILIATION_REQUIRED);
assertThat(transition.resultingState()).isEqualTo(COMMITTED);
@@ -101,7 +101,7 @@ void writesOffReservationAwaitingReconciliation() {
@Test
@DisplayName("상태 정보만으로 종료 명령 재사용을 판단하지 않는다")
void doesNotInferTerminalReplayFromStateAlone() {
- var transition = ReservationStateMachine.onActualKnown(COMMITTED);
+ var transition = ReservationStateMachine.commit(COMMITTED);
assertThat(transition.previousState()).isEqualTo(COMMITTED);
assertThat(transition.resultingState()).isEqualTo(COMMITTED);
From ee04990bcb7f05b1cf181c99b39b0721d6039ede Mon Sep 17 00:00:00 2001
From: rigu1
Date: Thu, 20 Aug 2026 03:43:51 +0900
Subject: [PATCH 04/13] =?UTF-8?q?feat(budget):=20=EC=98=88=EC=95=BD=20?=
=?UTF-8?q?=ED=9A=8C=EA=B3=84=20=EC=A0=95=EC=82=B0=EA=B3=BC=20=EC=A2=85?=
=?UTF-8?q?=EB=A3=8C=20=EB=AA=85=EB=A0=B9=20=EB=A9=B1=EB=93=B1=EC=84=B1=20?=
=?UTF-8?q?=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../budget/ReservationAccounting.java | 33 +
.../budget/ReservationStateMachine.java | 11 +
.../budget/internal/AppliedCommit.java | 51 ++
.../budget/internal/AppliedRelease.java | 41 ++
.../internal/InMemoryBudgetStateStore.java | 576 +++++++++++++--
.../internal/ReservationAccountingState.java | 200 +++++
.../budget/ReservationStateMachineTest.java | 10 +
.../internal/ReservationAccountingTest.java | 690 ++++++++++++++++++
8 files changed, 1534 insertions(+), 78 deletions(-)
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedCommit.java
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedRelease.java
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java
create mode 100644 token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
new file mode 100644
index 0000000..70cb39b
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
@@ -0,0 +1,33 @@
+package io.tokenpilot.budget;
+
+import io.tokenpilot.core.domain.Cost;
+
+/**
+ * 예약의 회계 상태와 금액을 변경하는 단일 진입점입니다.
+ */
+public interface ReservationAccounting {
+
+ /** 예약을 사용한 provider 호출 시작을 기록합니다. */
+ ReservationTransition markInFlight(ReservationId reservationId);
+
+ /** provider 호출 전에 사용하지 않은 예약을 해제합니다. */
+ ReservationTransition releaseBeforeDispatch(ReservationId reservationId);
+
+ /** provider가 미과금을 확인한 진행 중 예약을 해제합니다. */
+ ReservationTransition releaseConfirmedUnbilled(ReservationId reservationId);
+
+ /** 전달받은 actual 비용을 예약에 확정합니다. */
+ ReservationTransition commit(ReservationId reservationId, Cost actualCost);
+
+ /** actual을 확보하지 못한 예약을 정산 대기로 전환합니다. */
+ ReservationTransition markReconciliationRequired(ReservationId reservationId);
+
+ /** 정산 대기 중 전달받은 late actual 비용을 확정합니다. */
+ ReservationTransition reconcileLateActual(
+ ReservationId reservationId,
+ Cost actualCost
+ );
+
+ /** 후속 정산할 수 없는 pending 예약을 명시적으로 상각합니다. */
+ ReservationTransition writeOff(ReservationId reservationId);
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java
index d2fb37f..812b575 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java
@@ -28,6 +28,17 @@ public static ReservationTransition release(ReservationState currentState) {
);
}
+ /** 공급자가 미과금을 확인한 진행 중 예약을 해제합니다. */
+ public static ReservationTransition releaseConfirmedUnbilled(
+ ReservationState currentState
+ ) {
+ return transitionFrom(
+ currentState,
+ ReservationState.IN_FLIGHT,
+ ReservationState.RELEASED
+ );
+ }
+
/** 전달받은 actual을 확정하기 위한 상태 전이를 판단합니다. */
public static ReservationTransition commit(ReservationState currentState) {
return transitionFrom(
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedCommit.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedCommit.java
new file mode 100644
index 0000000..64b8355
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedCommit.java
@@ -0,0 +1,51 @@
+package io.tokenpilot.budget.internal;
+
+import io.tokenpilot.budget.AccountingTransitionStatus;
+import io.tokenpilot.budget.ReservationState;
+import io.tokenpilot.budget.ReservationTransition;
+import io.tokenpilot.core.domain.Cost;
+
+import java.util.Objects;
+
+import static io.tokenpilot.budget.AccountingTransitionStatus.CONFLICT;
+import static io.tokenpilot.budget.AccountingTransitionStatus.REUSED;
+
+/**
+ * 이미 적용된 commit의 종류, actual과 재호출 판단을 보관합니다.
+ */
+record AppliedCommit(Kind kind, Cost actualCost) {
+
+ AppliedCommit {
+ Objects.requireNonNull(kind, "kind must not be null");
+ Objects.requireNonNull(actualCost, "actualCost must not be null");
+ }
+
+ static AppliedCommit direct(Cost actualCost) {
+ return new AppliedCommit(Kind.DIRECT, actualCost);
+ }
+
+ static AppliedCommit lateActual(Cost actualCost) {
+ return new AppliedCommit(Kind.LATE_ACTUAL, actualCost);
+ }
+
+ ReservationTransition evaluate(
+ Kind requestedKind,
+ ReservationState state,
+ Cost requestedActualCost
+ ) {
+ Objects.requireNonNull(requestedKind, "requestedKind must not be null");
+ Objects.requireNonNull(state, "state must not be null");
+ Objects.requireNonNull(requestedActualCost, "requestedActualCost must not be null");
+
+ AccountingTransitionStatus status = kind == requestedKind
+ && actualCost.equals(requestedActualCost)
+ ? REUSED
+ : CONFLICT;
+ return ReservationTransition.unchanged(state, status);
+ }
+
+ enum Kind {
+ DIRECT,
+ LATE_ACTUAL
+ }
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedRelease.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedRelease.java
new file mode 100644
index 0000000..6daf30b
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedRelease.java
@@ -0,0 +1,41 @@
+package io.tokenpilot.budget.internal;
+
+import io.tokenpilot.budget.AccountingTransitionStatus;
+import io.tokenpilot.budget.ReservationState;
+import io.tokenpilot.budget.ReservationTransition;
+
+import java.util.Objects;
+
+import static io.tokenpilot.budget.AccountingTransitionStatus.CONFLICT;
+import static io.tokenpilot.budget.AccountingTransitionStatus.REUSED;
+
+/**
+ * 이미 적용된 release의 종류와 재호출 판단을 보관합니다.
+ */
+record AppliedRelease(Kind kind) {
+
+ AppliedRelease {
+ Objects.requireNonNull(kind, "kind must not be null");
+ }
+
+ static AppliedRelease beforeDispatch() {
+ return new AppliedRelease(Kind.BEFORE_DISPATCH);
+ }
+
+ static AppliedRelease confirmedUnbilled() {
+ return new AppliedRelease(Kind.CONFIRMED_UNBILLED);
+ }
+
+ ReservationTransition evaluate(Kind requestedKind, ReservationState state) {
+ Objects.requireNonNull(requestedKind, "requestedKind must not be null");
+ Objects.requireNonNull(state, "state must not be null");
+
+ AccountingTransitionStatus status = kind == requestedKind ? REUSED : CONFLICT;
+ return ReservationTransition.unchanged(state, status);
+ }
+
+ enum Kind {
+ BEFORE_DISPATCH,
+ CONFIRMED_UNBILLED
+ }
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
index 97bb3de..817d762 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
@@ -7,13 +7,18 @@
import io.tokenpilot.budget.BudgetSnapshot;
import io.tokenpilot.budget.BudgetStateStore;
import io.tokenpilot.budget.IdempotencyKey;
+import io.tokenpilot.budget.ReservationAccounting;
import io.tokenpilot.budget.ReservationId;
+import io.tokenpilot.budget.ReservationState;
+import io.tokenpilot.budget.ReservationStateMachine;
+import io.tokenpilot.budget.ReservationTransition;
import io.tokenpilot.core.domain.Cost;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicReference;
@@ -25,10 +30,12 @@
* bucket별 monitor가 조회·통화 검증·한도 검증·예약 갱신을 함께 보호하고,
* 별도의 idempotency index가 같은 요청의 중복 예약을 차단합니다.
*/
-public class InMemoryBudgetStateStore implements BudgetStateStore {
+public class InMemoryBudgetStateStore implements BudgetStateStore, ReservationAccounting {
private final ConcurrentMap store = new ConcurrentHashMap<>();
- private final ConcurrentMap idempotencyIndex =
+ private final ConcurrentMap idempotencyIndex =
+ new ConcurrentHashMap<>();
+ private final ConcurrentMap reservationIndex =
new ConcurrentHashMap<>();
private final Clock clock;
private final Supplier reservationIdGenerator;
@@ -79,88 +86,176 @@ public BudgetReservationResult checkAndReserve(BudgetReservationRequest request)
Objects.requireNonNull(request, "request must not be null");
AtomicReference result = new AtomicReference<>();
- idempotencyIndex.compute(request.idempotencyKey(), (ignored, existing) -> {
- if (existing != null) {
- BudgetSnapshot existingSnapshot = snapshot(
- existing.key(),
- existing.limit()
- );
- if (existing.matches(request)) {
- result.set(BudgetReservationResult.reused(existing, existingSnapshot));
- } else {
- result.set(BudgetReservationResult.conflict(
- existing,
- existingSnapshot,
- "동일 idempotency key에 다른 예약 요청이 사용되었습니다"
- ));
- }
- return existing;
+ idempotencyIndex.compute(
+ request.idempotencyKey(),
+ (ignored, existingReservationId) -> reserveOrReturnExisting(
+ request,
+ existingReservationId,
+ result
+ )
+ );
+
+ return Objects.requireNonNull(result.get(), "reservation result must be set");
+ }
+
+ @Override
+ public ReservationTransition markInFlight(ReservationId reservationId) {
+ return updateState(reservationId, ReservationStateMachine::onDispatch);
+ }
+
+ @Override
+ public ReservationTransition releaseBeforeDispatch(ReservationId reservationId) {
+ Bucket bucket = bucketFor(reservationId);
+ synchronized (bucket) {
+ ReservationAccountingState accountingState = accountingState(
+ bucket,
+ reservationId
+ );
+ BudgetReservation reservation = accountingState.reservation();
+ ReservationTransition transition =
+ accountingState.evaluateReleaseBeforeDispatch();
+ if (!transition.status().isApplied()) {
+ return transition;
}
- if (!request.limit().currency().equals(request.safeUpperBoundCost().currency())) {
- result.set(BudgetReservationResult.currencyMismatch(
- BudgetSnapshot.empty(request.key(), request.limit())
- ));
- return null;
+ BudgetReservation updated = withState(
+ reservation,
+ transition.resultingState()
+ );
+ releaseActiveReservation(
+ bucket,
+ accountingState,
+ accountingState.releasedBeforeDispatch(updated)
+ );
+ return transition;
+ }
+ }
+
+ @Override
+ public ReservationTransition releaseConfirmedUnbilled(ReservationId reservationId) {
+ Bucket bucket = bucketFor(reservationId);
+ synchronized (bucket) {
+ ReservationAccountingState accountingState = accountingState(
+ bucket,
+ reservationId
+ );
+ BudgetReservation reservation = accountingState.reservation();
+ ReservationTransition transition =
+ accountingState.evaluateConfirmedUnbilledRelease();
+ if (!transition.status().isApplied()) {
+ return transition;
}
- Bucket bucket = store.computeIfAbsent(
- request.key(),
- ignoredKey -> new Bucket(request.limit())
- );
- synchronized (bucket) {
- if (!bucket.limit.currency().equals(request.limit().currency())) {
- result.set(BudgetReservationResult.currencyMismatch(
- bucket.snapshot(request.key())
- ));
- return null;
- }
- if (!bucket.limit.equals(request.limit())) {
- result.set(BudgetReservationResult.conflict(
- null,
- bucket.snapshot(request.key()),
- "기존 budget bucket의 limit snapshot이 변경되었습니다"
- ));
- return null;
- }
-
- Cost projectedUsage = bucket.effectiveUsage().add(request.safeUpperBoundCost());
- if (projectedUsage.compareTo(request.limit()) >= 0) {
- result.set(BudgetReservationResult.blocked(
- bucket.snapshot(request.key()),
- "예약 후 사용량이 예산 한도에 도달하거나 초과합니다"
- ));
- return null;
- }
-
- ReservationId reservationId = Objects.requireNonNull(
- reservationIdGenerator.get(),
- "reservationIdGenerator returned null"
- );
- BudgetReservation reservation = BudgetReservation.reserved(
- reservationId,
- request,
- clock.instant()
- );
- if (bucket.reservationsById.containsKey(reservationId)) {
- throw new IllegalStateException(
- "reservationIdGenerator returned a duplicate reservation id"
+ BudgetReservation updated = withState(
+ reservation,
+ transition.resultingState()
+ );
+ releaseActiveReservation(
+ bucket,
+ accountingState,
+ accountingState.confirmedUnbilledReleased(updated)
+ );
+ return transition;
+ }
+ }
+
+ @Override
+ public ReservationTransition commit(ReservationId reservationId, Cost actualCost) {
+ Objects.requireNonNull(actualCost, "actualCost must not be null");
+ Bucket bucket = bucketFor(reservationId);
+ synchronized (bucket) {
+ ReservationAccountingState accountingState = accountingState(
+ bucket,
+ reservationId
+ );
+ ReservationTransition transition = accountingState.evaluateCommit(actualCost);
+ if (!transition.status().isApplied()) {
+ return transition;
+ }
+
+ commitActiveReservation(
+ bucket,
+ accountingState,
+ actualCost,
+ transition.resultingState()
+ );
+ return transition;
+ }
+ }
+
+ @Override
+ public ReservationTransition markReconciliationRequired(ReservationId reservationId) {
+ Bucket bucket = bucketFor(reservationId);
+ synchronized (bucket) {
+ ReservationAccountingState accountingState = accountingState(
+ bucket,
+ reservationId
+ );
+ BudgetReservation reservation = accountingState.reservation();
+ ReservationTransition transition =
+ ReservationStateMachine.markReconciliationRequired(
+ reservation.state()
);
- }
+ if (!transition.status().isApplied()) {
+ return transition;
+ }
- bucket.activeReservedCost = bucket.activeReservedCost.add(
- request.safeUpperBoundCost()
- );
- bucket.reservationsById.put(reservationId, reservation);
- result.set(BudgetReservationResult.created(
- reservation,
- bucket.snapshot(request.key())
- ));
- return reservation;
+ moveActiveReservationToPending(
+ bucket,
+ accountingState,
+ transition.resultingState()
+ );
+ return transition;
+ }
+ }
+
+ @Override
+ public ReservationTransition reconcileLateActual(
+ ReservationId reservationId,
+ Cost actualCost
+ ) {
+ Objects.requireNonNull(actualCost, "actualCost must not be null");
+ Bucket bucket = bucketFor(reservationId);
+ synchronized (bucket) {
+ ReservationAccountingState accountingState = accountingState(
+ bucket,
+ reservationId
+ );
+ ReservationTransition transition = accountingState.evaluateLateActual(actualCost);
+ if (!transition.status().isApplied()) {
+ return transition;
}
- });
- return Objects.requireNonNull(result.get(), "reservation result must be set");
+ commitPendingReservation(
+ bucket,
+ accountingState,
+ actualCost,
+ transition.resultingState()
+ );
+ return transition;
+ }
+ }
+
+ @Override
+ public ReservationTransition writeOff(ReservationId reservationId) {
+ Bucket bucket = bucketFor(reservationId);
+ synchronized (bucket) {
+ ReservationAccountingState accountingState = accountingState(
+ bucket,
+ reservationId
+ );
+ ReservationTransition transition = accountingState.evaluateWriteOff();
+ if (!transition.status().isApplied()) {
+ return transition;
+ }
+
+ writeOffPendingReservation(
+ bucket,
+ accountingState,
+ transition.resultingState()
+ );
+ return transition;
+ }
}
@Override
@@ -190,12 +285,325 @@ private void validateCurrency(Cost limit, Cost amount) {
}
}
+ private ReservationId reserveOrReturnExisting(
+ BudgetReservationRequest request,
+ ReservationId existingReservationId,
+ AtomicReference result
+ ) {
+ if (existingReservationId != null) {
+ result.set(resultForExistingReservation(existingReservationId, request));
+ return existingReservationId;
+ }
+
+ BudgetReservationResult reservationResult = reserveNewRequest(request);
+ result.set(reservationResult);
+ return reservationResult.reservationId();
+ }
+
+ private BudgetReservationResult resultForExistingReservation(
+ ReservationId existingReservationId,
+ BudgetReservationRequest request
+ ) {
+ Bucket bucket = bucketFor(existingReservationId);
+ synchronized (bucket) {
+ BudgetReservation existing = accountingState(
+ bucket,
+ existingReservationId
+ ).reservation();
+ BudgetSnapshot existingSnapshot = bucket.snapshot(existing.key());
+ if (existing.matches(request)) {
+ return BudgetReservationResult.reused(existing, existingSnapshot);
+ }
+ return BudgetReservationResult.conflict(
+ existing,
+ existingSnapshot,
+ "동일 idempotency key에 다른 예약 요청이 사용되었습니다"
+ );
+ }
+ }
+
+ private BudgetReservationResult reserveNewRequest(BudgetReservationRequest request) {
+ if (!request.limit().currency().equals(request.safeUpperBoundCost().currency())) {
+ return BudgetReservationResult.currencyMismatch(
+ BudgetSnapshot.empty(request.key(), request.limit())
+ );
+ }
+
+ Bucket bucket = store.computeIfAbsent(
+ request.key(),
+ ignored -> new Bucket(request.limit())
+ );
+ synchronized (bucket) {
+ return reserveInBucket(bucket, request);
+ }
+ }
+
+ private BudgetReservationResult reserveInBucket(
+ Bucket bucket,
+ BudgetReservationRequest request
+ ) {
+ if (!bucket.limit.currency().equals(request.limit().currency())) {
+ return BudgetReservationResult.currencyMismatch(
+ bucket.snapshot(request.key())
+ );
+ }
+ if (!bucket.limit.equals(request.limit())) {
+ return BudgetReservationResult.conflict(
+ null,
+ bucket.snapshot(request.key()),
+ "기존 budget bucket의 limit snapshot이 변경되었습니다"
+ );
+ }
+
+ Cost projectedUsage = bucket.effectiveUsage().add(request.safeUpperBoundCost());
+ if (projectedUsage.compareTo(request.limit()) >= 0) {
+ return BudgetReservationResult.blocked(
+ bucket.snapshot(request.key()),
+ "예약 후 사용량이 예산 한도에 도달하거나 초과합니다"
+ );
+ }
+
+ return createReservation(bucket, request);
+ }
+
+ private BudgetReservationResult createReservation(
+ Bucket bucket,
+ BudgetReservationRequest request
+ ) {
+ ReservationId reservationId = Objects.requireNonNull(
+ reservationIdGenerator.get(),
+ "reservationIdGenerator returned null"
+ );
+ BudgetReservation reservation = BudgetReservation.reserved(
+ reservationId,
+ request,
+ clock.instant()
+ );
+ if (bucket.reservationsById.containsKey(reservationId)) {
+ throw duplicateReservationId();
+ }
+ if (reservationIndex.putIfAbsent(reservationId, request.key()) != null) {
+ throw duplicateReservationId();
+ }
+
+ bucket.activeReservedCost = bucket.activeReservedCost.add(
+ request.safeUpperBoundCost()
+ );
+ bucket.reservationsById.put(
+ reservationId,
+ ReservationAccountingState.reserved(reservation)
+ );
+ return BudgetReservationResult.created(
+ reservation,
+ bucket.snapshot(request.key())
+ );
+ }
+
+ private static IllegalStateException duplicateReservationId() {
+ return new IllegalStateException(
+ "reservationIdGenerator returned a duplicate reservation id"
+ );
+ }
+
+ private ReservationTransition updateState(
+ ReservationId reservationId,
+ java.util.function.Function transitionRule
+ ) {
+ Objects.requireNonNull(transitionRule, "transitionRule must not be null");
+ Bucket bucket = bucketFor(reservationId);
+ synchronized (bucket) {
+ ReservationAccountingState accountingState = accountingState(
+ bucket,
+ reservationId
+ );
+ BudgetReservation reservation = accountingState.reservation();
+ ReservationTransition transition = Objects.requireNonNull(
+ transitionRule.apply(reservation.state()),
+ "transitionRule must return a transition"
+ );
+ if (transition.status().isApplied()) {
+ BudgetReservation updated = withState(
+ reservation,
+ transition.resultingState()
+ );
+ replaceReservationSnapshot(
+ bucket,
+ accountingState,
+ accountingState.withReservation(updated)
+ );
+ }
+ return transition;
+ }
+ }
+
+ private void commitActiveReservation(
+ Bucket bucket,
+ ReservationAccountingState accountingState,
+ Cost actualCost,
+ ReservationState resultingState
+ ) {
+ BudgetReservation reservation = accountingState.reservation();
+ Cost remainingReserved = subtract(
+ bucket.activeReservedCost,
+ reservation.amount()
+ );
+ Cost committed = bucket.committedCost.add(actualCost);
+ BudgetReservation updated = withState(reservation, resultingState);
+
+ bucket.activeReservedCost = remainingReserved;
+ bucket.committedCost = committed;
+ replaceReservationSnapshot(
+ bucket,
+ accountingState,
+ accountingState.committed(updated, actualCost)
+ );
+ }
+
+ private void releaseActiveReservation(
+ Bucket bucket,
+ ReservationAccountingState accountingState,
+ ReservationAccountingState updatedAccountingState
+ ) {
+ BudgetReservation reservation = accountingState.reservation();
+ Cost remainingReserved = subtract(
+ bucket.activeReservedCost,
+ reservation.amount()
+ );
+
+ bucket.activeReservedCost = remainingReserved;
+ replaceReservationSnapshot(
+ bucket,
+ accountingState,
+ updatedAccountingState
+ );
+ }
+
+ private void moveActiveReservationToPending(
+ Bucket bucket,
+ ReservationAccountingState accountingState,
+ ReservationState resultingState
+ ) {
+ BudgetReservation reservation = accountingState.reservation();
+ Cost remainingReserved = subtract(
+ bucket.activeReservedCost,
+ reservation.amount()
+ );
+ Cost pending = bucket.pendingReconciliationLiability.add(
+ reservation.amount()
+ );
+ BudgetReservation updated = withState(reservation, resultingState);
+
+ bucket.activeReservedCost = remainingReserved;
+ bucket.pendingReconciliationLiability = pending;
+ replaceReservationSnapshot(
+ bucket,
+ accountingState,
+ accountingState.withReservation(updated)
+ );
+ }
+
+ private void commitPendingReservation(
+ Bucket bucket,
+ ReservationAccountingState accountingState,
+ Cost actualCost,
+ ReservationState resultingState
+ ) {
+ BudgetReservation reservation = accountingState.reservation();
+ Cost remainingPending = subtract(
+ bucket.pendingReconciliationLiability,
+ reservation.amount()
+ );
+ Cost committed = bucket.committedCost.add(actualCost);
+ BudgetReservation updated = withState(reservation, resultingState);
+
+ bucket.pendingReconciliationLiability = remainingPending;
+ bucket.committedCost = committed;
+ replaceReservationSnapshot(
+ bucket,
+ accountingState,
+ accountingState.lateActualCommitted(updated, actualCost)
+ );
+ }
+
+ private void writeOffPendingReservation(
+ Bucket bucket,
+ ReservationAccountingState accountingState,
+ ReservationState resultingState
+ ) {
+ BudgetReservation reservation = accountingState.reservation();
+ Cost remainingPending = subtract(
+ bucket.pendingReconciliationLiability,
+ reservation.amount()
+ );
+ BudgetReservation updated = withState(reservation, resultingState);
+
+ bucket.pendingReconciliationLiability = remainingPending;
+ replaceReservationSnapshot(
+ bucket,
+ accountingState,
+ accountingState.withReservation(updated)
+ );
+ }
+
+ private void replaceReservationSnapshot(
+ Bucket bucket,
+ ReservationAccountingState previous,
+ ReservationAccountingState updated
+ ) {
+ BudgetReservation previousReservation = previous.reservation();
+ bucket.reservationsById.put(previousReservation.id(), updated);
+ }
+
+ private Bucket bucketFor(ReservationId reservationId) {
+ Objects.requireNonNull(reservationId, "reservationId must not be null");
+ BudgetKey key = reservationIndex.get(reservationId);
+ if (key == null) {
+ throw new IllegalArgumentException("reservation does not exist");
+ }
+ return Objects.requireNonNull(store.get(key), "reservation bucket must exist");
+ }
+
+ private static ReservationAccountingState accountingState(
+ Bucket bucket,
+ ReservationId reservationId
+ ) {
+ return Objects.requireNonNull(
+ bucket.reservationsById.get(reservationId),
+ "reservation must exist in its bucket"
+ );
+ }
+
+ private static Cost subtract(Cost total, Cost amount) {
+ if (total.compareTo(amount) < 0) {
+ throw new IllegalStateException("reserved cost must not become negative");
+ }
+ return Cost.of(total.value().subtract(amount.value()), total.currency());
+ }
+
+ private static BudgetReservation withState(
+ BudgetReservation reservation,
+ ReservationState state
+ ) {
+ return new BudgetReservation(
+ reservation.id(),
+ reservation.key(),
+ reservation.limit(),
+ reservation.amount(),
+ reservation.idempotencyKey(),
+ reservation.modelId(),
+ reservation.pricingPolicyId(),
+ reservation.catalogVersion(),
+ state,
+ reservation.createdAt()
+ );
+ }
+
private static final class Bucket {
private final Cost limit;
private Cost committedCost;
private Cost activeReservedCost;
private Cost pendingReconciliationLiability;
- private final Map reservationsById =
+ private final Map reservationsById =
new LinkedHashMap<>();
private Bucket(Cost limit) {
@@ -226,8 +634,20 @@ private BudgetSnapshot snapshot(BudgetKey key) {
committedCost,
activeReservedCost,
pendingReconciliationLiability,
- reservationsById.keySet()
+ activeReservationIds()
);
}
+
+ private Set activeReservationIds() {
+ return reservationsById.entrySet().stream()
+ .filter(entry -> {
+ ReservationState state = entry.getValue().reservation().state();
+ return state == ReservationState.RESERVED
+ || state == ReservationState.IN_FLIGHT;
+ })
+ .map(Map.Entry::getKey)
+ .collect(java.util.stream.Collectors.toUnmodifiableSet());
+ }
}
+
}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java
new file mode 100644
index 0000000..79373e9
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java
@@ -0,0 +1,200 @@
+package io.tokenpilot.budget.internal;
+
+import io.tokenpilot.budget.BudgetReservation;
+import io.tokenpilot.budget.ReservationStateMachine;
+import io.tokenpilot.budget.ReservationTransition;
+import io.tokenpilot.core.domain.Cost;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import static io.tokenpilot.budget.AccountingTransitionStatus.CONFLICT;
+import static io.tokenpilot.budget.AccountingTransitionStatus.CURRENCY_MISMATCH;
+import static io.tokenpilot.budget.AccountingTransitionStatus.REUSED;
+import static io.tokenpilot.budget.ReservationState.WRITTEN_OFF;
+
+/**
+ * 한 예약에 적용된 회계 명령을 판단하기 위한 내부 상태입니다.
+ */
+final class ReservationAccountingState {
+
+ private final BudgetReservation reservation;
+ private final Optional appliedCommit;
+ private final Optional appliedRelease;
+
+ private ReservationAccountingState(
+ BudgetReservation reservation,
+ Optional appliedCommit,
+ Optional appliedRelease
+ ) {
+ this.reservation = Objects.requireNonNull(
+ reservation,
+ "reservation must not be null"
+ );
+ this.appliedCommit = Objects.requireNonNull(
+ appliedCommit,
+ "appliedCommit must not be null"
+ );
+ this.appliedRelease = Objects.requireNonNull(
+ appliedRelease,
+ "appliedRelease must not be null"
+ );
+ if (appliedCommit.isPresent() && appliedRelease.isPresent()) {
+ throw new IllegalArgumentException("only one terminal command can be applied");
+ }
+ }
+
+ static ReservationAccountingState reserved(BudgetReservation reservation) {
+ return new ReservationAccountingState(
+ reservation,
+ Optional.empty(),
+ Optional.empty()
+ );
+ }
+
+ BudgetReservation reservation() {
+ return reservation;
+ }
+
+ ReservationTransition evaluateCommit(Cost actualCost) {
+ Objects.requireNonNull(actualCost, "actualCost must not be null");
+ if (hasDifferentCurrency(actualCost)) {
+ return ReservationTransition.unchanged(
+ reservation.state(),
+ CURRENCY_MISMATCH
+ );
+ }
+ if (appliedRelease.isPresent()) {
+ return ReservationTransition.unchanged(reservation.state(), CONFLICT);
+ }
+ return appliedCommit
+ .map(commit -> commit.evaluate(
+ AppliedCommit.Kind.DIRECT,
+ reservation.state(),
+ actualCost
+ ))
+ .orElseGet(() -> ReservationStateMachine.commit(reservation.state()));
+ }
+
+ ReservationTransition evaluateLateActual(Cost actualCost) {
+ Objects.requireNonNull(actualCost, "actualCost must not be null");
+ if (hasDifferentCurrency(actualCost)) {
+ return ReservationTransition.unchanged(
+ reservation.state(),
+ CURRENCY_MISMATCH
+ );
+ }
+ if (reservation.state() == WRITTEN_OFF) {
+ return ReservationTransition.unchanged(WRITTEN_OFF, CONFLICT);
+ }
+ if (appliedRelease.isPresent()) {
+ return ReservationTransition.unchanged(reservation.state(), CONFLICT);
+ }
+ return appliedCommit
+ .map(commit -> commit.evaluate(
+ AppliedCommit.Kind.LATE_ACTUAL,
+ reservation.state(),
+ actualCost
+ ))
+ .orElseGet(
+ () -> ReservationStateMachine.reconcileLateActual(
+ reservation.state()
+ )
+ );
+ }
+
+ ReservationTransition evaluateReleaseBeforeDispatch() {
+ if (appliedCommit.isPresent()) {
+ return ReservationTransition.unchanged(reservation.state(), CONFLICT);
+ }
+ return appliedRelease
+ .map(release -> release.evaluate(
+ AppliedRelease.Kind.BEFORE_DISPATCH,
+ reservation.state()
+ ))
+ .orElseGet(() -> ReservationStateMachine.release(reservation.state()));
+ }
+
+ ReservationTransition evaluateConfirmedUnbilledRelease() {
+ if (appliedCommit.isPresent()) {
+ return ReservationTransition.unchanged(reservation.state(), CONFLICT);
+ }
+ return appliedRelease
+ .map(release -> release.evaluate(
+ AppliedRelease.Kind.CONFIRMED_UNBILLED,
+ reservation.state()
+ ))
+ .orElseGet(
+ () -> ReservationStateMachine.releaseConfirmedUnbilled(
+ reservation.state()
+ )
+ );
+ }
+
+ ReservationTransition evaluateWriteOff() {
+ if (reservation.state() == WRITTEN_OFF) {
+ return ReservationTransition.unchanged(WRITTEN_OFF, REUSED);
+ }
+ if (reservation.state().isClosed()) {
+ return ReservationTransition.unchanged(
+ reservation.state(),
+ CONFLICT
+ );
+ }
+ return ReservationStateMachine.writeOff(reservation.state());
+ }
+
+ ReservationAccountingState withReservation(BudgetReservation updatedReservation) {
+ return new ReservationAccountingState(
+ updatedReservation,
+ appliedCommit,
+ appliedRelease
+ );
+ }
+
+ ReservationAccountingState releasedBeforeDispatch(
+ BudgetReservation updatedReservation
+ ) {
+ return new ReservationAccountingState(
+ updatedReservation,
+ Optional.empty(),
+ Optional.of(AppliedRelease.beforeDispatch())
+ );
+ }
+
+ ReservationAccountingState confirmedUnbilledReleased(
+ BudgetReservation updatedReservation
+ ) {
+ return new ReservationAccountingState(
+ updatedReservation,
+ Optional.empty(),
+ Optional.of(AppliedRelease.confirmedUnbilled())
+ );
+ }
+
+ ReservationAccountingState committed(
+ BudgetReservation updatedReservation,
+ Cost actualCost
+ ) {
+ return new ReservationAccountingState(
+ updatedReservation,
+ Optional.of(AppliedCommit.direct(actualCost)),
+ Optional.empty()
+ );
+ }
+
+ ReservationAccountingState lateActualCommitted(
+ BudgetReservation updatedReservation,
+ Cost actualCost
+ ) {
+ return new ReservationAccountingState(
+ updatedReservation,
+ Optional.of(AppliedCommit.lateActual(actualCost)),
+ Optional.empty()
+ );
+ }
+
+ private boolean hasDifferentCurrency(Cost actualCost) {
+ return !reservation.amount().currency().equals(actualCost.currency());
+ }
+}
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java
index 98de361..f642e25 100644
--- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java
@@ -58,6 +58,16 @@ void rejectsReleaseAfterDispatch() {
assertThat(transition.status()).isEqualTo(NOT_ALLOWED);
}
+ @Test
+ @DisplayName("provider가 미과금을 확인하면 진행 중인 예약을 해제한다")
+ void releasesInFlightReservationAfterNoChargeIsConfirmed() {
+ var transition = ReservationStateMachine.releaseConfirmedUnbilled(IN_FLIGHT);
+
+ assertThat(transition.previousState()).isEqualTo(IN_FLIGHT);
+ assertThat(transition.resultingState()).isEqualTo(RELEASED);
+ assertThat(transition.status()).isEqualTo(APPLIED);
+ }
+
@Test
@DisplayName("호출 후 actual을 전달해 commit하면 비용이 확정된다")
void commitsInFlightReservationWithActual() {
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java
new file mode 100644
index 0000000..4e263bd
--- /dev/null
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java
@@ -0,0 +1,690 @@
+package io.tokenpilot.budget.internal;
+
+import io.tokenpilot.budget.BudgetKey;
+import io.tokenpilot.budget.BudgetSnapshot;
+import io.tokenpilot.budget.BudgetWindow;
+import io.tokenpilot.budget.ReservationAccounting;
+import io.tokenpilot.budget.ReservationId;
+import io.tokenpilot.budget.ReservationTransition;
+import io.tokenpilot.core.domain.Cost;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Currency;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static io.tokenpilot.budget.AccountingTransitionStatus.CONFLICT;
+import static io.tokenpilot.budget.AccountingTransitionStatus.CURRENCY_MISMATCH;
+import static io.tokenpilot.budget.AccountingTransitionStatus.NOT_ALLOWED;
+import static io.tokenpilot.budget.AccountingTransitionStatus.REUSED;
+import static io.tokenpilot.budget.ReservationState.COMMITTED;
+import static io.tokenpilot.budget.ReservationState.IN_FLIGHT;
+import static io.tokenpilot.budget.ReservationState.RECONCILIATION_REQUIRED;
+import static io.tokenpilot.budget.ReservationState.RELEASED;
+import static io.tokenpilot.budget.ReservationState.RESERVED;
+import static io.tokenpilot.budget.ReservationState.WRITTEN_OFF;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ReservationAccountingTest {
+
+ private static final Currency USD = Currency.getInstance("USD");
+ private static final Cost LIMIT = usd("100.00");
+ private static final BudgetKey KEY = new BudgetKey(
+ "budget-policy",
+ "tenant",
+ "tenant-a",
+ BudgetWindow.parse("2026-08")
+ );
+ private static final Clock CLOCK = Clock.fixed(
+ Instant.parse("2026-08-15T12:34:56Z"),
+ ZoneOffset.UTC
+ );
+
+ @Test
+ @DisplayName("없는 예약을 commit하면 bucket 합계를 변경하지 않는다")
+ void keepsBucketUnchangedWhenReservationDoesNotExist() {
+ InMemoryBudgetStateStore store = store();
+ store.addCost(KEY, LIMIT, usd("10.00"));
+
+ assertThatThrownBy(
+ () -> store.commit(new ReservationId("missing"), usd("40.00"))
+ ).isInstanceOf(IllegalArgumentException.class);
+
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.committedCost()).isEqualTo(usd("10.00"));
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("변경 없는 결과는 bucket snapshot을 그대로 유지한다")
+ void preservesBucketSnapshotForEveryNoOpStatus() {
+ InMemoryBudgetStateStore reusedStore = store();
+ ReservationId reusedReservation = reserveInFlight(
+ reusedStore,
+ reusedStore,
+ usd("60.00")
+ );
+ reusedStore.commit(reusedReservation, usd("40.00"));
+ BudgetSnapshot beforeReused = reusedStore.snapshot(KEY, LIMIT);
+
+ ReservationTransition reused = reusedStore.commit(
+ reusedReservation,
+ usd("40.00")
+ );
+
+ assertThat(reused.status()).isEqualTo(REUSED);
+ assertThat(reusedStore.snapshot(KEY, LIMIT)).isEqualTo(beforeReused);
+
+ InMemoryBudgetStateStore conflictStore = store();
+ ReservationId conflictReservation = reserveInFlight(
+ conflictStore,
+ conflictStore,
+ usd("60.00")
+ );
+ conflictStore.commit(conflictReservation, usd("40.00"));
+ BudgetSnapshot beforeConflict = conflictStore.snapshot(KEY, LIMIT);
+
+ ReservationTransition conflict = conflictStore.commit(
+ conflictReservation,
+ usd("50.00")
+ );
+
+ assertThat(conflict.status()).isEqualTo(CONFLICT);
+ assertThat(conflictStore.snapshot(KEY, LIMIT)).isEqualTo(beforeConflict);
+
+ InMemoryBudgetStateStore notAllowedStore = store();
+ ReservationId notAllowedReservation = reserveInFlight(
+ notAllowedStore,
+ notAllowedStore,
+ usd("60.00")
+ );
+ BudgetSnapshot beforeNotAllowed = notAllowedStore.snapshot(KEY, LIMIT);
+
+ ReservationTransition notAllowed = notAllowedStore.releaseBeforeDispatch(
+ notAllowedReservation
+ );
+
+ assertThat(notAllowed.status()).isEqualTo(NOT_ALLOWED);
+ assertThat(notAllowedStore.snapshot(KEY, LIMIT)).isEqualTo(beforeNotAllowed);
+
+ InMemoryBudgetStateStore mismatchStore = store();
+ ReservationId mismatchReservation = reserveInFlight(
+ mismatchStore,
+ mismatchStore,
+ usd("60.00")
+ );
+ BudgetSnapshot beforeMismatch = mismatchStore.snapshot(KEY, LIMIT);
+
+ ReservationTransition mismatch = mismatchStore.commit(
+ mismatchReservation,
+ Cost.of(new BigDecimal("40.00"), Currency.getInstance("KRW"))
+ );
+
+ assertThat(mismatch.status()).isEqualTo(CURRENCY_MISMATCH);
+ assertThat(mismatchStore.snapshot(KEY, LIMIT)).isEqualTo(beforeMismatch);
+ }
+
+ @Test
+ @DisplayName("estimate보다 작은 actual을 commit하면 남은 예약액을 해제한다")
+ void commitsActualBelowEstimate() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+
+ ReservationTransition transition = store.commit(reservationId, usd("40.00"));
+
+ assertThat(transition)
+ .isEqualTo(ReservationTransition.applied(IN_FLIGHT, COMMITTED));
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("40.00"));
+ }
+
+ @Test
+ @DisplayName("estimate와 같은 actual을 commit하면 예약액을 확정 비용으로 옮긴다")
+ void commitsActualEqualToEstimate() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+
+ ReservationTransition transition = store.commit(reservationId, usd("60.00"));
+
+ assertThat(transition)
+ .isEqualTo(ReservationTransition.applied(IN_FLIGHT, COMMITTED));
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("60.00"));
+ assertThat(snapshot.activeReservationIds()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("estimate보다 큰 actual을 commit하면 초과 비용까지 모두 반영한다")
+ void commitsActualAboveEstimate() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+
+ ReservationTransition transition = store.commit(reservationId, usd("80.00"));
+
+ assertThat(transition)
+ .isEqualTo(ReservationTransition.applied(IN_FLIGHT, COMMITTED));
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("80.00"));
+ }
+
+ @Test
+ @DisplayName("actual 통화가 다르면 commit하지 않고 통화 불일치를 반환한다")
+ void rejectsCommitWhenActualCurrencyDiffers() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+
+ ReservationTransition transition = store.commit(
+ reservationId,
+ Cost.of(new BigDecimal("40.00"), Currency.getInstance("KRW"))
+ );
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(IN_FLIGHT, CURRENCY_MISMATCH)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("60.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("같은 actual로 commit을 반복하면 기존 정산을 재사용한다")
+ void reusesCommitWhenActualIsUnchanged() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.commit(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.commit(reservationId, usd("40.00"));
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(COMMITTED, REUSED)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("40.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("다른 actual로 commit을 반복하면 기존 정산을 보존하고 충돌을 반환한다")
+ void rejectsCommitWhenActualHasChanged() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.commit(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.commit(reservationId, usd("50.00"));
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(COMMITTED, CONFLICT)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("40.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("commit 이후 release를 요청하면 기존 정산을 보존하고 충돌을 반환한다")
+ void rejectsReleaseAfterCommit() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.commit(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.releaseConfirmedUnbilled(reservationId);
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(COMMITTED, CONFLICT)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("40.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("commit 이후 호출 전 release를 요청하면 기존 정산을 보존하고 충돌을 반환한다")
+ void rejectsPreDispatchReleaseAfterCommit() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.commit(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.releaseBeforeDispatch(reservationId);
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(COMMITTED, CONFLICT)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("40.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("확인된 미과금 release 이후 commit하면 기존 해제를 보존하고 충돌을 반환한다")
+ void rejectsCommitAfterConfirmedUnbilledRelease() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.releaseConfirmedUnbilled(reservationId);
+
+ ReservationTransition transition = store.commit(reservationId, usd("40.00"));
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(RELEASED, CONFLICT)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("actual을 확보하지 못해 정산 대기를 요청하면 estimate를 pending으로 옮긴다")
+ void movesEstimateToPendingWhenActualIsUnavailable() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+
+ ReservationTransition transition = store.markReconciliationRequired(reservationId);
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.applied(IN_FLIGHT, RECONCILIATION_REQUIRED)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("60.00"));
+ }
+
+ @Test
+ @DisplayName("actual이 0이면 비용을 확정하고 actual을 모르면 estimate를 pending으로 유지한다")
+ void distinguishesZeroActualFromUnavailableActual() {
+ InMemoryBudgetStateStore zeroActualStore = store();
+ ReservationId zeroActualReservation = reserveInFlight(
+ zeroActualStore,
+ zeroActualStore,
+ usd("60.00")
+ );
+ InMemoryBudgetStateStore unavailableActualStore = store();
+ ReservationId unavailableActualReservation = reserveInFlight(
+ unavailableActualStore,
+ unavailableActualStore,
+ usd("60.00")
+ );
+
+ ReservationTransition zeroActualTransition = zeroActualStore.commit(
+ zeroActualReservation,
+ usd("0.00")
+ );
+ ReservationTransition unavailableActualTransition =
+ unavailableActualStore.markReconciliationRequired(
+ unavailableActualReservation
+ );
+
+ assertThat(zeroActualTransition).isEqualTo(
+ ReservationTransition.applied(IN_FLIGHT, COMMITTED)
+ );
+ BudgetSnapshot zeroActualSnapshot = zeroActualStore.snapshot(KEY, LIMIT);
+ assertThat(zeroActualSnapshot.committedCost()).isEqualTo(usd("0.00"));
+ assertThat(zeroActualSnapshot.pendingReconciliationLiability())
+ .isEqualTo(usd("0.00"));
+
+ assertThat(unavailableActualTransition).isEqualTo(
+ ReservationTransition.applied(IN_FLIGHT, RECONCILIATION_REQUIRED)
+ );
+ BudgetSnapshot unavailableActualSnapshot = unavailableActualStore.snapshot(KEY, LIMIT);
+ assertThat(unavailableActualSnapshot.committedCost()).isEqualTo(usd("0.00"));
+ assertThat(unavailableActualSnapshot.pendingReconciliationLiability())
+ .isEqualTo(usd("60.00"));
+ }
+
+ @Test
+ @DisplayName("late actual을 전달해 reconcile하면 pending을 제거하고 비용을 확정한다")
+ void reconcilesPendingReservationWithLateActual() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.markReconciliationRequired(reservationId);
+
+ ReservationTransition transition = store.reconcileLateActual(
+ reservationId,
+ usd("40.00")
+ );
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.applied(RECONCILIATION_REQUIRED, COMMITTED)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("40.00"));
+ }
+
+ @Test
+ @DisplayName("late actual 통화가 다르면 pending을 유지하고 통화 불일치를 반환한다")
+ void rejectsLateActualWhenCurrencyDiffers() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.markReconciliationRequired(reservationId);
+
+ ReservationTransition transition = store.reconcileLateActual(
+ reservationId,
+ Cost.of(new BigDecimal("40.00"), Currency.getInstance("KRW"))
+ );
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(
+ RECONCILIATION_REQUIRED,
+ CURRENCY_MISMATCH
+ )
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("60.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("같은 late actual로 reconcile을 반복하면 기존 정산을 재사용한다")
+ void reusesLateActualWhenActualIsUnchanged() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.markReconciliationRequired(reservationId);
+ store.reconcileLateActual(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.reconcileLateActual(
+ reservationId,
+ usd("40.00")
+ );
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(COMMITTED, REUSED)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("40.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("다른 late actual로 reconcile을 반복하면 기존 정산을 보존하고 충돌을 반환한다")
+ void rejectsLateActualWhenActualHasChanged() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.markReconciliationRequired(reservationId);
+ store.reconcileLateActual(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.reconcileLateActual(
+ reservationId,
+ usd("50.00")
+ );
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(COMMITTED, CONFLICT)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("40.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("late actual 정산 이후 일반 commit을 요청하면 기존 정산을 보존하고 충돌을 반환한다")
+ void rejectsDirectCommitAfterLateActualCommit() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.markReconciliationRequired(reservationId);
+ store.reconcileLateActual(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.commit(reservationId, usd("40.00"));
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(COMMITTED, CONFLICT)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("40.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("일반 commit 이후 late actual 정산을 요청하면 기존 정산을 보존하고 충돌을 반환한다")
+ void rejectsLateActualAfterDirectCommit() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.commit(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.reconcileLateActual(
+ reservationId,
+ usd("40.00")
+ );
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(COMMITTED, CONFLICT)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("40.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("provider 호출 전에 release하면 예약액을 해제한다")
+ void releasesReservationBeforeDispatch() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserve(store, usd("60.00"));
+
+ ReservationTransition transition = store.releaseBeforeDispatch(reservationId);
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.applied(RESERVED, RELEASED)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("호출 전 release를 반복하면 기존 해제를 재사용한다")
+ void reusesReleaseBeforeDispatch() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserve(store, usd("60.00"));
+ store.releaseBeforeDispatch(reservationId);
+
+ ReservationTransition transition = store.releaseBeforeDispatch(reservationId);
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(RELEASED, REUSED)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("호출 전 release 이후 확인된 미과금 release를 요청하면 충돌을 반환한다")
+ void rejectsConfirmedUnbilledReleaseAfterPreDispatchRelease() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserve(store, usd("60.00"));
+ store.releaseBeforeDispatch(reservationId);
+
+ ReservationTransition transition = store.releaseConfirmedUnbilled(reservationId);
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(RELEASED, CONFLICT)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("provider 호출을 시작한 예약에는 호출 전 release를 적용하지 않는다")
+ void keepsInFlightReservationWhenPreDispatchReleaseIsRequested() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+
+ ReservationTransition transition = store.releaseBeforeDispatch(reservationId);
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(
+ IN_FLIGHT,
+ NOT_ALLOWED
+ )
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("60.00"));
+ }
+
+ @Test
+ @DisplayName("provider가 미과금을 확인하면 진행 중인 예약액을 해제한다")
+ void releasesInFlightReservationWhenProviderConfirmsNoCharge() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+
+ ReservationTransition transition = store.releaseConfirmedUnbilled(reservationId);
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.applied(IN_FLIGHT, RELEASED)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("확인된 미과금 release를 반복하면 기존 해제를 재사용한다")
+ void reusesConfirmedUnbilledRelease() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.releaseConfirmedUnbilled(reservationId);
+
+ ReservationTransition transition = store.releaseConfirmedUnbilled(reservationId);
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(RELEASED, REUSED)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("정산 대기 예약을 write-off하면 pending만 제거한다")
+ void writesOffPendingReservation() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.markReconciliationRequired(reservationId);
+
+ ReservationTransition transition = store.writeOff(reservationId);
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.applied(RECONCILIATION_REQUIRED, WRITTEN_OFF)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("write-off를 반복하면 기존 상각을 재사용한다")
+ void reusesWriteOff() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.markReconciliationRequired(reservationId);
+ store.writeOff(reservationId);
+
+ ReservationTransition transition = store.writeOff(reservationId);
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(WRITTEN_OFF, REUSED)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("write-off 이후 late actual을 요청하면 기존 상각을 보존하고 충돌을 반환한다")
+ void rejectsLateActualAfterWriteOff() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.markReconciliationRequired(reservationId);
+ store.writeOff(reservationId);
+
+ ReservationTransition transition = store.reconcileLateActual(
+ reservationId,
+ usd("40.00")
+ );
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(WRITTEN_OFF, CONFLICT)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ @Test
+ @DisplayName("late actual 정산 이후 write-off를 요청하면 기존 정산을 보존하고 충돌을 반환한다")
+ void rejectsWriteOffAfterLateActual() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.markReconciliationRequired(reservationId);
+ store.reconcileLateActual(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.writeOff(reservationId);
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(COMMITTED, CONFLICT)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("40.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
+ private static ReservationId reserveInFlight(
+ InMemoryBudgetStateStore store,
+ ReservationAccounting accounting,
+ Cost estimate
+ ) {
+ ReservationId reservationId = reserve(store, estimate);
+ accounting.markInFlight(reservationId);
+ return reservationId;
+ }
+
+ private static ReservationId reserve(
+ InMemoryBudgetStateStore store,
+ Cost estimate
+ ) {
+ return store.checkAndReserve(
+ KEY,
+ LIMIT,
+ estimate,
+ "request-1"
+ ).reservationId();
+ }
+
+ private static InMemoryBudgetStateStore store() {
+ AtomicInteger sequence = new AtomicInteger();
+ return new InMemoryBudgetStateStore(
+ CLOCK,
+ () -> new ReservationId("reservation-" + sequence.incrementAndGet())
+ );
+ }
+
+ private static Cost usd(String amount) {
+ return Cost.of(new BigDecimal(amount), USD);
+ }
+}
From 6200e18fb3eba7bcd80255c96a8ed801851601b4 Mon Sep 17 00:00:00 2001
From: rigu1
Date: Thu, 20 Aug 2026 04:16:51 +0900
Subject: [PATCH 05/13] =?UTF-8?q?feat(budget):=20=EC=98=88=EC=95=BD=20?=
=?UTF-8?q?=EC=8B=9C=EC=A0=90=20=EA=B0=80=EA=B2=A9=20=EA=B8=B0=EB=B0=98=20?=
=?UTF-8?q?actual=20=EC=A0=95=EC=82=B0=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../tokenpilot/budget/ActualUsageCommand.java | 38 ++
.../tokenpilot/budget/BudgetReservation.java | 91 ++++-
.../budget/BudgetReservationRequest.java | 121 +++++-
.../tokenpilot/budget/BudgetStateStore.java | 30 +-
.../budget/ReservationAccounting.java | 13 +-
.../budget/ReservationReconciliation.java | 78 ++++
.../internal/InMemoryBudgetStateStore.java | 61 ++-
.../internal/LedgerBudgetComponents.java | 30 ++
.../internal/ReservationUsageAccounting.java | 86 ++++
.../internal/BudgetReservationStoreTest.java | 44 +++
.../internal/ReservationAccountingTest.java | 60 +--
.../ReservationReconciliationTest.java | 374 ++++++++++++++++++
.../io/tokenpilot/core/CostCalculator.java | 19 +
13 files changed, 996 insertions(+), 49 deletions(-)
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/ActualUsageCommand.java
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationReconciliation.java
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationUsageAccounting.java
create mode 100644 token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ActualUsageCommand.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ActualUsageCommand.java
new file mode 100644
index 0000000..1b4ca84
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ActualUsageCommand.java
@@ -0,0 +1,38 @@
+package io.tokenpilot.budget;
+
+import io.tokenpilot.core.domain.TokenUsage;
+import io.tokenpilot.core.domain.UsageSource;
+
+import java.util.Objects;
+
+/**
+ * provider가 보고한 actual usage를 하나의 예약에 정산하기 위한 명령입니다.
+ */
+public record ActualUsageCommand(
+ String requestId,
+ String attemptId,
+ ReservationId reservationId,
+ TokenUsage usage,
+ String responseModelId
+) {
+
+ public ActualUsageCommand {
+ requestId = requireText(requestId, "requestId");
+ attemptId = requireText(attemptId, "attemptId");
+ Objects.requireNonNull(reservationId, "reservationId must not be null");
+ Objects.requireNonNull(usage, "usage must not be null");
+ if (usage.source() == UsageSource.UNAVAILABLE) {
+ throw new IllegalArgumentException(
+ "usage must be available for actual reconciliation"
+ );
+ }
+ responseModelId = requireText(responseModelId, "responseModelId");
+ }
+
+ private static String requireText(String value, String name) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(name + " must not be blank");
+ }
+ return value;
+ }
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java
index d848ffd..ffc32f8 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java
@@ -1,9 +1,11 @@
package io.tokenpilot.budget;
import io.tokenpilot.core.domain.Cost;
+import io.tokenpilot.core.domain.PricingSnapshot;
import java.time.Instant;
import java.util.Objects;
+import java.util.Optional;
/**
* 예산 bucket에 생성된 immutable 예약 snapshot입니다.
@@ -13,10 +15,12 @@ public record BudgetReservation(
BudgetKey key,
Cost limit,
Cost amount,
+ String requestId,
IdempotencyKey idempotencyKey,
String modelId,
String pricingPolicyId,
String catalogVersion,
+ Optional pricingSnapshot,
ReservationState state,
Instant createdAt
) {
@@ -26,6 +30,7 @@ public record BudgetReservation(
Objects.requireNonNull(key, "key must not be null");
Objects.requireNonNull(limit, "limit must not be null");
Objects.requireNonNull(amount, "amount must not be null");
+ requestId = requireText(requestId, "requestId");
Objects.requireNonNull(idempotencyKey, "idempotencyKey must not be null");
Objects.requireNonNull(state, "state must not be null");
Objects.requireNonNull(createdAt, "createdAt must not be null");
@@ -38,6 +43,62 @@ public record BudgetReservation(
modelId = optionalText(modelId, "modelId");
pricingPolicyId = optionalText(pricingPolicyId, "pricingPolicyId");
catalogVersion = optionalText(catalogVersion, "catalogVersion");
+ pricingSnapshot = Objects.requireNonNull(
+ pricingSnapshot,
+ "pricingSnapshot must not be null"
+ );
+ if (pricingSnapshot.isPresent()) {
+ PricingSnapshot snapshot = pricingSnapshot.orElseThrow();
+ requireSnapshotText(modelId, snapshot.modelId(), "modelId");
+ requireSnapshotText(
+ pricingPolicyId,
+ snapshot.pricingPolicyId(),
+ "pricingPolicyId"
+ );
+ requireSnapshotText(
+ catalogVersion,
+ snapshot.catalogVersion(),
+ "catalogVersion"
+ );
+ if (!limit.currency().equals(snapshot.currency())) {
+ throw new IllegalArgumentException(
+ "pricing snapshot must use the budget currency"
+ );
+ }
+ }
+ }
+
+ /**
+ * @deprecated request ID와 idempotency key를 같은 값으로 사용하는 호환 생성자입니다.
+ * 신규 예약은 {@link #reserved(ReservationId, BudgetReservationRequest, Instant)}로 생성하세요.
+ */
+ @Deprecated(since = "0.1.0", forRemoval = false)
+ public BudgetReservation(
+ ReservationId id,
+ BudgetKey key,
+ Cost limit,
+ Cost amount,
+ IdempotencyKey idempotencyKey,
+ String modelId,
+ String pricingPolicyId,
+ String catalogVersion,
+ ReservationState state,
+ Instant createdAt
+ ) {
+ this(
+ id,
+ key,
+ limit,
+ amount,
+ idempotencyKey.value(),
+ idempotencyKey,
+ modelId,
+ pricingPolicyId,
+ catalogVersion,
+ Optional.empty(),
+ state,
+ createdAt
+ );
}
public static BudgetReservation reserved(
@@ -51,10 +112,12 @@ public static BudgetReservation reserved(
request.key(),
request.limit(),
request.safeUpperBoundCost(),
+ request.requestId(),
request.idempotencyKey(),
request.modelId(),
request.pricingPolicyId(),
request.catalogVersion(),
+ request.pricingSnapshot(),
ReservationState.RESERVED,
createdAt
);
@@ -64,10 +127,17 @@ public boolean matches(BudgetReservationRequest request) {
return key.equals(request.key())
&& limit.equals(request.limit())
&& amount.equals(request.safeUpperBoundCost())
+ && requestId.equals(request.requestId())
&& idempotencyKey.equals(request.idempotencyKey())
&& Objects.equals(modelId, request.modelId())
&& Objects.equals(pricingPolicyId, request.pricingPolicyId())
- && Objects.equals(catalogVersion, request.catalogVersion());
+ && Objects.equals(catalogVersion, request.catalogVersion())
+ && pricingSnapshot.equals(request.pricingSnapshot());
+ }
+
+ /** 이 예약이 지정한 provider 요청에 속하는지 확인합니다. */
+ public boolean belongsTo(String candidateRequestId) {
+ return requestId.equals(candidateRequestId);
}
private static String optionalText(String value, String name) {
@@ -76,4 +146,23 @@ private static String optionalText(String value, String name) {
}
return value;
}
+
+ private static String requireText(String value, String name) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(name + " must not be blank");
+ }
+ return value;
+ }
+
+ private static void requireSnapshotText(
+ String value,
+ String snapshotValue,
+ String name
+ ) {
+ if (!Objects.equals(value, snapshotValue)) {
+ throw new IllegalArgumentException(
+ name + " must match the pricing snapshot"
+ );
+ }
+ }
}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java
index 56e063b..3e62c3f 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java
@@ -1,8 +1,10 @@
package io.tokenpilot.budget;
import io.tokenpilot.core.domain.Cost;
+import io.tokenpilot.core.domain.PricingSnapshot;
import java.util.Objects;
+import java.util.Optional;
/**
* 호출 전 안전 상한 비용을 예산 bucket에 예약하기 위한 immutable 요청입니다.
@@ -15,16 +17,19 @@ public record BudgetReservationRequest(
BudgetKey key,
Cost limit,
Cost safeUpperBoundCost,
+ String requestId,
IdempotencyKey idempotencyKey,
String modelId,
String pricingPolicyId,
- String catalogVersion
+ String catalogVersion,
+ Optional pricingSnapshot
) {
public BudgetReservationRequest {
Objects.requireNonNull(key, "key must not be null");
Objects.requireNonNull(limit, "limit must not be null");
Objects.requireNonNull(safeUpperBoundCost, "safeUpperBoundCost must not be null");
+ requestId = requireText(requestId, "requestId");
Objects.requireNonNull(idempotencyKey, "idempotencyKey must not be null");
if (limit.value().signum() <= 0) {
throw new IllegalArgumentException("limit must be greater than zero");
@@ -32,8 +37,90 @@ public record BudgetReservationRequest(
modelId = optionalText(modelId, "modelId");
pricingPolicyId = optionalText(pricingPolicyId, "pricingPolicyId");
catalogVersion = optionalText(catalogVersion, "catalogVersion");
+ pricingSnapshot = Objects.requireNonNull(
+ pricingSnapshot,
+ "pricingSnapshot must not be null"
+ );
+ if (pricingSnapshot.isPresent()) {
+ PricingSnapshot snapshot = pricingSnapshot.orElseThrow();
+ modelId = snapshotText(modelId, snapshot.modelId(), "modelId");
+ pricingPolicyId = snapshotText(
+ pricingPolicyId,
+ snapshot.pricingPolicyId(),
+ "pricingPolicyId"
+ );
+ catalogVersion = snapshotText(
+ catalogVersion,
+ snapshot.catalogVersion(),
+ "catalogVersion"
+ );
+ if (!limit.currency().equals(snapshot.currency())) {
+ throw new IllegalArgumentException(
+ "pricing snapshot must use the budget currency"
+ );
+ }
+ }
}
+ /**
+ * @deprecated request ID와 idempotency key를 같은 값으로 사용하는 호환 생성자입니다.
+ * 신규 호출은 두 값을 명시하는 canonical 생성자를 사용하세요.
+ */
+ @Deprecated(since = "0.1.0", forRemoval = false)
+ public BudgetReservationRequest(
+ BudgetKey key,
+ Cost limit,
+ Cost safeUpperBoundCost,
+ IdempotencyKey idempotencyKey,
+ String modelId,
+ String pricingPolicyId,
+ String catalogVersion
+ ) {
+ this(
+ key,
+ limit,
+ safeUpperBoundCost,
+ idempotencyKey.value(),
+ idempotencyKey,
+ modelId,
+ pricingPolicyId,
+ catalogVersion,
+ Optional.empty()
+ );
+ }
+
+ /**
+ * @deprecated request ID와 idempotency key를 같은 값으로 사용하는 호환 생성자입니다.
+ * 신규 호출은 두 값을 명시하는 canonical 생성자를 사용하세요.
+ */
+ @Deprecated(since = "0.1.0", forRemoval = false)
+ public BudgetReservationRequest(
+ BudgetKey key,
+ Cost limit,
+ Cost safeUpperBoundCost,
+ IdempotencyKey idempotencyKey,
+ String modelId,
+ String pricingPolicyId,
+ String catalogVersion,
+ Optional pricingSnapshot
+ ) {
+ this(
+ key,
+ limit,
+ safeUpperBoundCost,
+ idempotencyKey.value(),
+ idempotencyKey,
+ modelId,
+ pricingPolicyId,
+ catalogVersion,
+ pricingSnapshot
+ );
+ }
+
+ /**
+ * @deprecated request ID와 idempotency key를 같은 값으로 사용하는 호환 생성자입니다.
+ */
+ @Deprecated(since = "0.1.0", forRemoval = false)
public BudgetReservationRequest(
BudgetKey key,
Cost limit,
@@ -44,13 +131,19 @@ public BudgetReservationRequest(
key,
limit,
safeUpperBoundCost,
+ idempotencyKey,
new IdempotencyKey(idempotencyKey),
null,
null,
- null
+ null,
+ Optional.empty()
);
}
+ /**
+ * @deprecated request ID와 idempotency key를 같은 값으로 사용하는 호환 생성자입니다.
+ */
+ @Deprecated(since = "0.1.0", forRemoval = false)
public BudgetReservationRequest(
BudgetKey key,
Cost limit,
@@ -64,10 +157,12 @@ public BudgetReservationRequest(
key,
limit,
safeUpperBoundCost,
+ idempotencyKey,
new IdempotencyKey(idempotencyKey),
modelId,
pricingPolicyId,
- catalogVersion
+ catalogVersion,
+ Optional.empty()
);
}
@@ -77,4 +172,24 @@ private static String optionalText(String value, String name) {
}
return value;
}
+
+ private static String requireText(String value, String name) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(name + " must not be blank");
+ }
+ return value;
+ }
+
+ private static String snapshotText(
+ String value,
+ String snapshotValue,
+ String name
+ ) {
+ if (value != null && !value.equals(snapshotValue)) {
+ throw new IllegalArgumentException(
+ name + " must match the pricing snapshot"
+ );
+ }
+ return snapshotValue;
+ }
}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java
index cfe8b14..cbc545e 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java
@@ -11,7 +11,7 @@ public interface BudgetStateStore {
* 기존 확정 비용 조회 API입니다. 예약 금액은 포함하지 않습니다.
*
* 새 provider admission 경계는 {@link #snapshot(BudgetKey, Cost)} 또는
- * {@link #checkAndReserve(BudgetKey, Cost, Cost, String)}를 사용해야 합니다.
+ * {@link #checkAndReserve(BudgetReservationRequest)}를 사용해야 합니다.
*/
Cost getAccumulatedCost(BudgetKey key, Cost limit);
@@ -31,7 +31,9 @@ public interface BudgetStateStore {
* @param safeUpperBoundCost 예약할 보수적 비용 상한
* @param idempotencyKey 중복 요청 식별자
* @return 생성·재사용·차단·충돌·통화 불일치 결과
+ * @deprecated request ID와 idempotency key를 분리하는 overload 또는 요청 객체를 사용하세요.
*/
+ @Deprecated(since = "0.1.0", forRemoval = false)
default BudgetReservationResult checkAndReserve(
BudgetKey key,
Cost limit,
@@ -48,7 +50,10 @@ default BudgetReservationResult checkAndReserve(
/**
* typed idempotency key를 사용하는 원자적 예약 overload입니다.
+ *
+ * @deprecated request ID와 idempotency key를 분리하는 overload 또는 요청 객체를 사용하세요.
*/
+ @Deprecated(since = "0.1.0", forRemoval = false)
default BudgetReservationResult checkAndReserve(
BudgetKey key,
Cost limit,
@@ -66,6 +71,29 @@ default BudgetReservationResult checkAndReserve(
));
}
+ /**
+ * 요청 상관관계와 중복 방지 식별자를 분리하는 원자적 예약 overload입니다.
+ */
+ default BudgetReservationResult checkAndReserve(
+ BudgetKey key,
+ Cost limit,
+ Cost safeUpperBoundCost,
+ String requestId,
+ IdempotencyKey idempotencyKey
+ ) {
+ return checkAndReserve(new BudgetReservationRequest(
+ key,
+ limit,
+ safeUpperBoundCost,
+ requestId,
+ idempotencyKey,
+ null,
+ null,
+ null,
+ java.util.Optional.empty()
+ ));
+ }
+
/**
* 모델·가격 snapshot metadata를 포함한 원자적 예약 요청입니다.
*/
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
index 70cb39b..d17b977 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
@@ -1,7 +1,5 @@
package io.tokenpilot.budget;
-import io.tokenpilot.core.domain.Cost;
-
/**
* 예약의 회계 상태와 금액을 변경하는 단일 진입점입니다.
*/
@@ -16,17 +14,14 @@ public interface ReservationAccounting {
/** provider가 미과금을 확인한 진행 중 예약을 해제합니다. */
ReservationTransition releaseConfirmedUnbilled(ReservationId reservationId);
- /** 전달받은 actual 비용을 예약에 확정합니다. */
- ReservationTransition commit(ReservationId reservationId, Cost actualCost);
+ /** provider actual usage를 예약 시점 가격으로 계산하여 확정합니다. */
+ ReservationReconciliation commit(ActualUsageCommand command);
/** actual을 확보하지 못한 예약을 정산 대기로 전환합니다. */
ReservationTransition markReconciliationRequired(ReservationId reservationId);
- /** 정산 대기 중 전달받은 late actual 비용을 확정합니다. */
- ReservationTransition reconcileLateActual(
- ReservationId reservationId,
- Cost actualCost
- );
+ /** 늦게 도착한 provider actual usage를 예약 시점 가격으로 계산하여 확정합니다. */
+ ReservationReconciliation reconcileLateActual(ActualUsageCommand command);
/** 후속 정산할 수 없는 pending 예약을 명시적으로 상각합니다. */
ReservationTransition writeOff(ReservationId reservationId);
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationReconciliation.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationReconciliation.java
new file mode 100644
index 0000000..14f3889
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationReconciliation.java
@@ -0,0 +1,78 @@
+package io.tokenpilot.budget;
+
+import io.tokenpilot.core.domain.Cost;
+import io.tokenpilot.core.domain.PricingSnapshot;
+
+import java.math.BigDecimal;
+import java.util.Currency;
+import java.util.Objects;
+
+/**
+ * 한 예약의 estimate와 actual 비용을 연결한 회계 정산 결과입니다.
+ */
+public record ReservationReconciliation(
+ String requestId,
+ String attemptId,
+ ReservationId reservationId,
+ BudgetKey budgetKey,
+ String responseModelId,
+ PricingSnapshot pricingSnapshot,
+ Cost estimate,
+ Cost actual,
+ ReservationTransition transition
+) {
+
+ public ReservationReconciliation {
+ requestId = requireText(requestId, "requestId");
+ attemptId = requireText(attemptId, "attemptId");
+ Objects.requireNonNull(reservationId, "reservationId must not be null");
+ Objects.requireNonNull(budgetKey, "budgetKey must not be null");
+ responseModelId = requireText(responseModelId, "responseModelId");
+ Objects.requireNonNull(pricingSnapshot, "pricingSnapshot must not be null");
+ Objects.requireNonNull(estimate, "estimate must not be null");
+ Objects.requireNonNull(actual, "actual must not be null");
+ Objects.requireNonNull(transition, "transition must not be null");
+ if (!estimate.currency().equals(actual.currency())) {
+ throw new IllegalArgumentException(
+ "estimate and actual must use the same currency"
+ );
+ }
+ if (!estimate.currency().equals(pricingSnapshot.currency())) {
+ throw new IllegalArgumentException(
+ "reconciliation costs must use the pricing snapshot currency"
+ );
+ }
+ }
+
+ /** 예약 시점 pricing snapshot의 request model입니다. */
+ public String requestModelId() {
+ return pricingSnapshot.modelId();
+ }
+
+ /** 예약 시점 pricing policy 식별자입니다. */
+ public String pricingPolicyId() {
+ return pricingSnapshot.pricingPolicyId();
+ }
+
+ /** 예약 시점 model catalog version입니다. */
+ public String catalogVersion() {
+ return pricingSnapshot.catalogVersion();
+ }
+
+ /** actual에서 estimate를 뺀 signed 비용 차이입니다. */
+ public BigDecimal delta() {
+ return actual.value().subtract(estimate.value());
+ }
+
+ /** estimate와 actual이 사용하는 통화입니다. */
+ public Currency currency() {
+ return actual.currency();
+ }
+
+ private static String requireText(String value, String name) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(name + " must not be blank");
+ }
+ return value;
+ }
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
index 817d762..331fd3e 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
@@ -1,5 +1,6 @@
package io.tokenpilot.budget.internal;
+import io.tokenpilot.budget.ActualUsageCommand;
import io.tokenpilot.budget.BudgetKey;
import io.tokenpilot.budget.BudgetReservation;
import io.tokenpilot.budget.BudgetReservationRequest;
@@ -9,10 +10,13 @@
import io.tokenpilot.budget.IdempotencyKey;
import io.tokenpilot.budget.ReservationAccounting;
import io.tokenpilot.budget.ReservationId;
+import io.tokenpilot.budget.ReservationReconciliation;
import io.tokenpilot.budget.ReservationState;
import io.tokenpilot.budget.ReservationStateMachine;
import io.tokenpilot.budget.ReservationTransition;
+import io.tokenpilot.core.CostCalculator;
import io.tokenpilot.core.domain.Cost;
+import io.tokenpilot.core.internal.LedgerComponents;
import java.time.Clock;
import java.util.LinkedHashMap;
@@ -39,20 +43,40 @@ public class InMemoryBudgetStateStore implements BudgetStateStore, ReservationAc
new ConcurrentHashMap<>();
private final Clock clock;
private final Supplier reservationIdGenerator;
+ private final ReservationUsageAccounting usageAccounting;
public InMemoryBudgetStateStore() {
- this(Clock.systemUTC(), ReservationId::random);
+ this(
+ Clock.systemUTC(),
+ ReservationId::random,
+ LedgerComponents.defaultCostCalculator()
+ );
}
public InMemoryBudgetStateStore(
Clock clock,
Supplier reservationIdGenerator
+ ) {
+ this(
+ clock,
+ reservationIdGenerator,
+ LedgerComponents.defaultCostCalculator()
+ );
+ }
+
+ public InMemoryBudgetStateStore(
+ Clock clock,
+ Supplier reservationIdGenerator,
+ CostCalculator costCalculator
) {
this.clock = Objects.requireNonNull(clock, "clock must not be null");
this.reservationIdGenerator = Objects.requireNonNull(
reservationIdGenerator,
"reservationIdGenerator must not be null"
);
+ this.usageAccounting = new ReservationUsageAccounting(
+ Objects.requireNonNull(costCalculator, "costCalculator must not be null")
+ );
}
@Override
@@ -159,8 +183,7 @@ public ReservationTransition releaseConfirmedUnbilled(ReservationId reservationI
}
}
- @Override
- public ReservationTransition commit(ReservationId reservationId, Cost actualCost) {
+ ReservationTransition commitCost(ReservationId reservationId, Cost actualCost) {
Objects.requireNonNull(actualCost, "actualCost must not be null");
Bucket bucket = bucketFor(reservationId);
synchronized (bucket) {
@@ -183,6 +206,16 @@ public ReservationTransition commit(ReservationId reservationId, Cost actualCost
}
}
+ @Override
+ public ReservationReconciliation commit(ActualUsageCommand command) {
+ Objects.requireNonNull(command, "command must not be null");
+ return usageAccounting.commit(
+ command,
+ reservationForAccounting(command.reservationId()),
+ this::commitCost
+ );
+ }
+
@Override
public ReservationTransition markReconciliationRequired(ReservationId reservationId) {
Bucket bucket = bucketFor(reservationId);
@@ -209,8 +242,7 @@ public ReservationTransition markReconciliationRequired(ReservationId reservatio
}
}
- @Override
- public ReservationTransition reconcileLateActual(
+ ReservationTransition reconcileLateActualCost(
ReservationId reservationId,
Cost actualCost
) {
@@ -236,6 +268,16 @@ public ReservationTransition reconcileLateActual(
}
}
+ @Override
+ public ReservationReconciliation reconcileLateActual(ActualUsageCommand command) {
+ Objects.requireNonNull(command, "command must not be null");
+ return usageAccounting.reconcileLateActual(
+ command,
+ reservationForAccounting(command.reservationId()),
+ this::reconcileLateActualCost
+ );
+ }
+
@Override
public ReservationTransition writeOff(ReservationId reservationId) {
Bucket bucket = bucketFor(reservationId);
@@ -285,6 +327,13 @@ private void validateCurrency(Cost limit, Cost amount) {
}
}
+ BudgetReservation reservationForAccounting(ReservationId reservationId) {
+ Bucket bucket = bucketFor(reservationId);
+ synchronized (bucket) {
+ return accountingState(bucket, reservationId).reservation();
+ }
+ }
+
private ReservationId reserveOrReturnExisting(
BudgetReservationRequest request,
ReservationId existingReservationId,
@@ -589,10 +638,12 @@ private static BudgetReservation withState(
reservation.key(),
reservation.limit(),
reservation.amount(),
+ reservation.requestId(),
reservation.idempotencyKey(),
reservation.modelId(),
reservation.pricingPolicyId(),
reservation.catalogVersion(),
+ reservation.pricingSnapshot(),
state,
reservation.createdAt()
);
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java
index 438df18..e41eaa1 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java
@@ -3,9 +3,12 @@
import io.tokenpilot.budget.BudgetEvaluator;
import io.tokenpilot.budget.BudgetPolicy;
import io.tokenpilot.budget.BudgetStateStore;
+import io.tokenpilot.budget.ReservationAccounting;
import io.tokenpilot.budget.ReservationId;
+import io.tokenpilot.core.CostCalculator;
import java.time.Clock;
+import java.util.Objects;
import java.util.function.Supplier;
/**
@@ -27,6 +30,33 @@ public static BudgetStateStore inMemoryBudgetStateStore(
return new InMemoryBudgetStateStore(clock, reservationIdGenerator);
}
+ public static BudgetStateStore inMemoryBudgetStateStore(
+ Clock clock,
+ Supplier reservationIdGenerator,
+ CostCalculator costCalculator
+ ) {
+ return new InMemoryBudgetStateStore(
+ clock,
+ reservationIdGenerator,
+ costCalculator
+ );
+ }
+
+ /**
+ * 예약을 생성한 store와 동일한 객체의 회계 명령 진입점을 반환합니다.
+ */
+ public static ReservationAccounting reservationAccounting(
+ BudgetStateStore stateStore
+ ) {
+ Objects.requireNonNull(stateStore, "stateStore must not be null");
+ if (stateStore instanceof ReservationAccounting accounting) {
+ return accounting;
+ }
+ throw new IllegalArgumentException(
+ "stateStore must support reservation accounting"
+ );
+ }
+
public static BudgetEvaluator defaultBudgetEvaluator(
BudgetStateStore store,
BudgetPolicy policy,
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationUsageAccounting.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationUsageAccounting.java
new file mode 100644
index 0000000..cd02ec4
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationUsageAccounting.java
@@ -0,0 +1,86 @@
+package io.tokenpilot.budget.internal;
+
+import io.tokenpilot.budget.ActualUsageCommand;
+import io.tokenpilot.budget.BudgetReservation;
+import io.tokenpilot.budget.ReservationId;
+import io.tokenpilot.budget.ReservationReconciliation;
+import io.tokenpilot.budget.ReservationTransition;
+import io.tokenpilot.core.CostCalculator;
+import io.tokenpilot.core.domain.Cost;
+import io.tokenpilot.core.domain.PricingSnapshot;
+
+import java.util.Objects;
+import java.util.function.BiFunction;
+
+/**
+ * actual usage를 예약 시점 가격으로 계산하고 저장소 상태 전이에 연결합니다.
+ */
+final class ReservationUsageAccounting {
+
+ private final CostCalculator costCalculator;
+
+ ReservationUsageAccounting(CostCalculator costCalculator) {
+ this.costCalculator = Objects.requireNonNull(
+ costCalculator,
+ "costCalculator must not be null"
+ );
+ }
+
+ ReservationReconciliation commit(
+ ActualUsageCommand command,
+ BudgetReservation reservation,
+ BiFunction transition
+ ) {
+ return reconcile(command, reservation, transition);
+ }
+
+ ReservationReconciliation reconcileLateActual(
+ ActualUsageCommand command,
+ BudgetReservation reservation,
+ BiFunction transition
+ ) {
+ return reconcile(command, reservation, transition);
+ }
+
+ private ReservationReconciliation reconcile(
+ ActualUsageCommand command,
+ BudgetReservation reservation,
+ BiFunction transition
+ ) {
+ Objects.requireNonNull(command, "command must not be null");
+ Objects.requireNonNull(reservation, "reservation must not be null");
+ Objects.requireNonNull(transition, "transition must not be null");
+ if (!reservation.belongsTo(command.requestId())) {
+ throw new IllegalArgumentException(
+ "requestId must match the reservation request"
+ );
+ }
+
+ PricingSnapshot snapshot = reservation.pricingSnapshot().orElseThrow(
+ () -> new IllegalStateException(
+ "reservation does not contain a pricing snapshot"
+ )
+ );
+ Cost actualCost = costCalculator.calculate(command.usage(), snapshot);
+ if (!snapshot.currency().equals(actualCost.currency())) {
+ throw new IllegalStateException(
+ "calculated cost must use the pricing snapshot currency"
+ );
+ }
+ ReservationTransition appliedTransition = transition.apply(
+ reservation.id(),
+ actualCost
+ );
+ return new ReservationReconciliation(
+ command.requestId(),
+ command.attemptId(),
+ reservation.id(),
+ reservation.key(),
+ command.responseModelId(),
+ snapshot,
+ reservation.amount(),
+ actualCost,
+ appliedTransition
+ );
+ }
+}
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationStoreTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationStoreTest.java
index 7b74714..2e3cdb4 100644
--- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationStoreTest.java
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationStoreTest.java
@@ -10,6 +10,8 @@
import io.tokenpilot.budget.ReservationStatus;
import io.tokenpilot.budget.ReservationState;
import io.tokenpilot.core.domain.Cost;
+import io.tokenpilot.core.domain.PricingSnapshot;
+import io.tokenpilot.core.domain.TokenType;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
@@ -17,6 +19,8 @@
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Currency;
+import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
@@ -64,6 +68,27 @@ class BudgetReservationStoreTest {
.containsExactly(result.reservation().id());
}
+ @Test
+ void 예약은_요청_시점의_exact_pricing_snapshot을_보관한다() {
+ InMemoryBudgetStateStore store = store();
+ PricingSnapshot snapshot = pricingSnapshot("0.10", "0.20");
+
+ BudgetReservationResult result = store.checkAndReserve(
+ new BudgetReservationRequest(
+ KEY,
+ LIMIT,
+ usd("60.00"),
+ new IdempotencyKey("request-1"),
+ "gpt-4o-mini",
+ "pricing-v1",
+ "catalog-v1",
+ Optional.of(snapshot)
+ )
+ );
+
+ assertThat(result.reservation().pricingSnapshot()).contains(snapshot);
+ }
+
@Test
void safe_upper_bound가_한도에_도달하면_예약하지_않고_BLOCKED를_반환한다() {
InMemoryBudgetStateStore store = store();
@@ -319,4 +344,23 @@ private static BudgetReservationRequest request(
private static Cost usd(String amount) {
return Cost.of(new BigDecimal(amount), USD);
}
+
+ private static PricingSnapshot pricingSnapshot(
+ String promptRate,
+ String completionRate
+ ) {
+ return new PricingSnapshot(
+ "gpt-4o-mini",
+ "pricing-v1",
+ "catalog-v1",
+ CLOCK.instant(),
+ Map.of(
+ TokenType.PROMPT,
+ new BigDecimal(promptRate),
+ TokenType.COMPLETION,
+ new BigDecimal(completionRate)
+ ),
+ USD
+ );
+ }
}
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java
index 4e263bd..1c03eff 100644
--- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java
@@ -52,7 +52,7 @@ void keepsBucketUnchangedWhenReservationDoesNotExist() {
store.addCost(KEY, LIMIT, usd("10.00"));
assertThatThrownBy(
- () -> store.commit(new ReservationId("missing"), usd("40.00"))
+ () -> store.commitCost(new ReservationId("missing"), usd("40.00"))
).isInstanceOf(IllegalArgumentException.class);
BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
@@ -70,10 +70,10 @@ void preservesBucketSnapshotForEveryNoOpStatus() {
reusedStore,
usd("60.00")
);
- reusedStore.commit(reusedReservation, usd("40.00"));
+ reusedStore.commitCost(reusedReservation, usd("40.00"));
BudgetSnapshot beforeReused = reusedStore.snapshot(KEY, LIMIT);
- ReservationTransition reused = reusedStore.commit(
+ ReservationTransition reused = reusedStore.commitCost(
reusedReservation,
usd("40.00")
);
@@ -87,10 +87,10 @@ void preservesBucketSnapshotForEveryNoOpStatus() {
conflictStore,
usd("60.00")
);
- conflictStore.commit(conflictReservation, usd("40.00"));
+ conflictStore.commitCost(conflictReservation, usd("40.00"));
BudgetSnapshot beforeConflict = conflictStore.snapshot(KEY, LIMIT);
- ReservationTransition conflict = conflictStore.commit(
+ ReservationTransition conflict = conflictStore.commitCost(
conflictReservation,
usd("50.00")
);
@@ -121,7 +121,7 @@ void preservesBucketSnapshotForEveryNoOpStatus() {
);
BudgetSnapshot beforeMismatch = mismatchStore.snapshot(KEY, LIMIT);
- ReservationTransition mismatch = mismatchStore.commit(
+ ReservationTransition mismatch = mismatchStore.commitCost(
mismatchReservation,
Cost.of(new BigDecimal("40.00"), Currency.getInstance("KRW"))
);
@@ -136,7 +136,7 @@ void commitsActualBelowEstimate() {
InMemoryBudgetStateStore store = store();
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
- ReservationTransition transition = store.commit(reservationId, usd("40.00"));
+ ReservationTransition transition = store.commitCost(reservationId, usd("40.00"));
assertThat(transition)
.isEqualTo(ReservationTransition.applied(IN_FLIGHT, COMMITTED));
@@ -151,7 +151,7 @@ void commitsActualEqualToEstimate() {
InMemoryBudgetStateStore store = store();
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
- ReservationTransition transition = store.commit(reservationId, usd("60.00"));
+ ReservationTransition transition = store.commitCost(reservationId, usd("60.00"));
assertThat(transition)
.isEqualTo(ReservationTransition.applied(IN_FLIGHT, COMMITTED));
@@ -167,7 +167,7 @@ void commitsActualAboveEstimate() {
InMemoryBudgetStateStore store = store();
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
- ReservationTransition transition = store.commit(reservationId, usd("80.00"));
+ ReservationTransition transition = store.commitCost(reservationId, usd("80.00"));
assertThat(transition)
.isEqualTo(ReservationTransition.applied(IN_FLIGHT, COMMITTED));
@@ -182,7 +182,7 @@ void rejectsCommitWhenActualCurrencyDiffers() {
InMemoryBudgetStateStore store = store();
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
- ReservationTransition transition = store.commit(
+ ReservationTransition transition = store.commitCost(
reservationId,
Cost.of(new BigDecimal("40.00"), Currency.getInstance("KRW"))
);
@@ -200,9 +200,9 @@ void rejectsCommitWhenActualCurrencyDiffers() {
void reusesCommitWhenActualIsUnchanged() {
InMemoryBudgetStateStore store = store();
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
- store.commit(reservationId, usd("40.00"));
+ store.commitCost(reservationId, usd("40.00"));
- ReservationTransition transition = store.commit(reservationId, usd("40.00"));
+ ReservationTransition transition = store.commitCost(reservationId, usd("40.00"));
assertThat(transition).isEqualTo(
ReservationTransition.unchanged(COMMITTED, REUSED)
@@ -218,9 +218,9 @@ void reusesCommitWhenActualIsUnchanged() {
void rejectsCommitWhenActualHasChanged() {
InMemoryBudgetStateStore store = store();
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
- store.commit(reservationId, usd("40.00"));
+ store.commitCost(reservationId, usd("40.00"));
- ReservationTransition transition = store.commit(reservationId, usd("50.00"));
+ ReservationTransition transition = store.commitCost(reservationId, usd("50.00"));
assertThat(transition).isEqualTo(
ReservationTransition.unchanged(COMMITTED, CONFLICT)
@@ -236,7 +236,7 @@ void rejectsCommitWhenActualHasChanged() {
void rejectsReleaseAfterCommit() {
InMemoryBudgetStateStore store = store();
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
- store.commit(reservationId, usd("40.00"));
+ store.commitCost(reservationId, usd("40.00"));
ReservationTransition transition = store.releaseConfirmedUnbilled(reservationId);
@@ -254,7 +254,7 @@ void rejectsReleaseAfterCommit() {
void rejectsPreDispatchReleaseAfterCommit() {
InMemoryBudgetStateStore store = store();
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
- store.commit(reservationId, usd("40.00"));
+ store.commitCost(reservationId, usd("40.00"));
ReservationTransition transition = store.releaseBeforeDispatch(reservationId);
@@ -274,7 +274,7 @@ void rejectsCommitAfterConfirmedUnbilledRelease() {
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
store.releaseConfirmedUnbilled(reservationId);
- ReservationTransition transition = store.commit(reservationId, usd("40.00"));
+ ReservationTransition transition = store.commitCost(reservationId, usd("40.00"));
assertThat(transition).isEqualTo(
ReservationTransition.unchanged(RELEASED, CONFLICT)
@@ -317,7 +317,7 @@ void distinguishesZeroActualFromUnavailableActual() {
usd("60.00")
);
- ReservationTransition zeroActualTransition = zeroActualStore.commit(
+ ReservationTransition zeroActualTransition = zeroActualStore.commitCost(
zeroActualReservation,
usd("0.00")
);
@@ -350,7 +350,7 @@ void reconcilesPendingReservationWithLateActual() {
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
store.markReconciliationRequired(reservationId);
- ReservationTransition transition = store.reconcileLateActual(
+ ReservationTransition transition = store.reconcileLateActualCost(
reservationId,
usd("40.00")
);
@@ -370,7 +370,7 @@ void rejectsLateActualWhenCurrencyDiffers() {
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
store.markReconciliationRequired(reservationId);
- ReservationTransition transition = store.reconcileLateActual(
+ ReservationTransition transition = store.reconcileLateActualCost(
reservationId,
Cost.of(new BigDecimal("40.00"), Currency.getInstance("KRW"))
);
@@ -392,9 +392,9 @@ void reusesLateActualWhenActualIsUnchanged() {
InMemoryBudgetStateStore store = store();
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
store.markReconciliationRequired(reservationId);
- store.reconcileLateActual(reservationId, usd("40.00"));
+ store.reconcileLateActualCost(reservationId, usd("40.00"));
- ReservationTransition transition = store.reconcileLateActual(
+ ReservationTransition transition = store.reconcileLateActualCost(
reservationId,
usd("40.00")
);
@@ -414,9 +414,9 @@ void rejectsLateActualWhenActualHasChanged() {
InMemoryBudgetStateStore store = store();
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
store.markReconciliationRequired(reservationId);
- store.reconcileLateActual(reservationId, usd("40.00"));
+ store.reconcileLateActualCost(reservationId, usd("40.00"));
- ReservationTransition transition = store.reconcileLateActual(
+ ReservationTransition transition = store.reconcileLateActualCost(
reservationId,
usd("50.00")
);
@@ -436,9 +436,9 @@ void rejectsDirectCommitAfterLateActualCommit() {
InMemoryBudgetStateStore store = store();
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
store.markReconciliationRequired(reservationId);
- store.reconcileLateActual(reservationId, usd("40.00"));
+ store.reconcileLateActualCost(reservationId, usd("40.00"));
- ReservationTransition transition = store.commit(reservationId, usd("40.00"));
+ ReservationTransition transition = store.commitCost(reservationId, usd("40.00"));
assertThat(transition).isEqualTo(
ReservationTransition.unchanged(COMMITTED, CONFLICT)
@@ -454,9 +454,9 @@ void rejectsDirectCommitAfterLateActualCommit() {
void rejectsLateActualAfterDirectCommit() {
InMemoryBudgetStateStore store = store();
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
- store.commit(reservationId, usd("40.00"));
+ store.commitCost(reservationId, usd("40.00"));
- ReservationTransition transition = store.reconcileLateActual(
+ ReservationTransition transition = store.reconcileLateActualCost(
reservationId,
usd("40.00")
);
@@ -621,7 +621,7 @@ void rejectsLateActualAfterWriteOff() {
store.markReconciliationRequired(reservationId);
store.writeOff(reservationId);
- ReservationTransition transition = store.reconcileLateActual(
+ ReservationTransition transition = store.reconcileLateActualCost(
reservationId,
usd("40.00")
);
@@ -641,7 +641,7 @@ void rejectsWriteOffAfterLateActual() {
InMemoryBudgetStateStore store = store();
ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
store.markReconciliationRequired(reservationId);
- store.reconcileLateActual(reservationId, usd("40.00"));
+ store.reconcileLateActualCost(reservationId, usd("40.00"));
ReservationTransition transition = store.writeOff(reservationId);
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
new file mode 100644
index 0000000..08bcae8
--- /dev/null
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
@@ -0,0 +1,374 @@
+package io.tokenpilot.budget.internal;
+
+import io.tokenpilot.budget.ActualUsageCommand;
+import io.tokenpilot.budget.BudgetKey;
+import io.tokenpilot.budget.BudgetReservationRequest;
+import io.tokenpilot.budget.BudgetStateStore;
+import io.tokenpilot.budget.BudgetWindow;
+import io.tokenpilot.budget.IdempotencyKey;
+import io.tokenpilot.budget.ReservationId;
+import io.tokenpilot.budget.ReservationAccounting;
+import io.tokenpilot.budget.ReservationReconciliation;
+import io.tokenpilot.core.CostCalculator;
+import io.tokenpilot.core.domain.Cost;
+import io.tokenpilot.core.domain.PricingPlan;
+import io.tokenpilot.core.domain.PricingSnapshot;
+import io.tokenpilot.core.domain.TokenType;
+import io.tokenpilot.core.domain.TokenUsage;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Currency;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static io.tokenpilot.budget.ReservationState.COMMITTED;
+import static io.tokenpilot.budget.ReservationState.IN_FLIGHT;
+import static io.tokenpilot.budget.ReservationState.RECONCILIATION_REQUIRED;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ReservationReconciliationTest {
+
+ private static final Currency USD = Currency.getInstance("USD");
+ private static final Cost LIMIT = usd("100.00");
+ private static final BudgetKey KEY = new BudgetKey(
+ "budget-policy",
+ "tenant",
+ "tenant-a",
+ BudgetWindow.parse("2026-08")
+ );
+ private static final Clock CLOCK = Clock.fixed(
+ Instant.parse("2026-08-15T12:34:56Z"),
+ ZoneOffset.UTC
+ );
+
+ @Test
+ @DisplayName("예약 시점 pricing snapshot으로 actual 비용을 한 번만 계산한다")
+ void calculatesActualCostOnceWithReservedPricingSnapshot() {
+ AtomicInteger calculationCount = new AtomicInteger();
+ AtomicReference calculatedPlan = new AtomicReference<>();
+ CostCalculator calculator = (usage, plan) -> {
+ calculationCount.incrementAndGet();
+ calculatedPlan.set(plan);
+ return usd("40.00");
+ };
+ InMemoryBudgetStateStore store = store(calculator);
+ PricingSnapshot snapshot = pricingSnapshot();
+ ReservationId reservationId = reserve(store, snapshot, usd("60.00"));
+ store.markInFlight(reservationId);
+
+ ReservationReconciliation reconciliation = store.commit(
+ new ActualUsageCommand(
+ "request-1",
+ "attempt-1",
+ reservationId,
+ TokenUsage.from(100, 50),
+ "gpt-4o-mini-response"
+ )
+ );
+
+ assertThat(calculationCount).hasValue(1);
+ assertThat(calculatedPlan.get().modelId()).isEqualTo(snapshot.modelId());
+ assertThat(calculatedPlan.get().pricingPolicyId())
+ .isEqualTo(snapshot.pricingPolicyId());
+ assertThat(calculatedPlan.get().rates()).isEqualTo(snapshot.rates());
+ assertThat(calculatedPlan.get().currency()).isEqualTo(snapshot.currency());
+ assertThat(reconciliation.transition().previousState()).isEqualTo(IN_FLIGHT);
+ assertThat(reconciliation.transition().resultingState()).isEqualTo(COMMITTED);
+ }
+
+ @Test
+ @DisplayName("actual이 estimate보다 작으면 음수 delta를 반환한다")
+ void returnsNegativeDeltaWhenActualIsBelowEstimate() {
+ ReservationReconciliation reconciliation = reconcile("60.00", "40.00");
+
+ assertThat(reconciliation.delta()).isEqualByComparingTo("-20.00");
+ }
+
+ @Test
+ @DisplayName("actual이 estimate와 같으면 0 delta를 반환한다")
+ void returnsZeroDeltaWhenActualEqualsEstimate() {
+ ReservationReconciliation reconciliation = reconcile("60.00", "60.00");
+
+ assertThat(reconciliation.delta()).isEqualByComparingTo("0.00");
+ }
+
+ @Test
+ @DisplayName("actual이 estimate보다 크면 양수 delta를 반환한다")
+ void returnsPositiveDeltaWhenActualIsAboveEstimate() {
+ ReservationReconciliation reconciliation = reconcile("60.00", "80.00");
+
+ assertThat(reconciliation.delta()).isEqualByComparingTo("20.00");
+ }
+
+ @Test
+ @DisplayName("정산 결과는 요청과 시도, 예약, 모델, pricing 정보를 연결한다")
+ void correlatesRequestReservationModelsAndPricing() {
+ InMemoryBudgetStateStore store = store((usage, plan) -> usd("40.00"));
+ PricingSnapshot snapshot = pricingSnapshot();
+ ReservationId reservationId = reserve(store, snapshot, usd("60.00"));
+ store.markInFlight(reservationId);
+
+ ReservationReconciliation reconciliation = store.commit(
+ command(reservationId)
+ );
+
+ assertThat(reconciliation.requestId()).isEqualTo("request-1");
+ assertThat(reconciliation.attemptId()).isEqualTo("attempt-1");
+ assertThat(reconciliation.reservationId()).isEqualTo(reservationId);
+ assertThat(reconciliation.budgetKey()).isEqualTo(KEY);
+ assertThat(reconciliation.requestModelId()).isEqualTo(snapshot.modelId());
+ assertThat(reconciliation.responseModelId())
+ .isEqualTo("gpt-4o-mini-response");
+ assertThat(reconciliation.pricingPolicyId())
+ .isEqualTo(snapshot.pricingPolicyId());
+ assertThat(reconciliation.catalogVersion())
+ .isEqualTo(snapshot.catalogVersion());
+ assertThat(reconciliation.estimate()).isEqualTo(usd("60.00"));
+ assertThat(reconciliation.actual()).isEqualTo(usd("40.00"));
+ assertThat(reconciliation.currency()).isEqualTo(USD);
+ assertThat(reconciliation.transition().previousState()).isEqualTo(IN_FLIGHT);
+ assertThat(reconciliation.transition().resultingState()).isEqualTo(COMMITTED);
+ }
+
+ @Test
+ @DisplayName("request ID와 idempotency key가 달라도 예약된 요청을 정산한다")
+ void reconcilesWhenRequestIdDiffersFromIdempotencyKey() {
+ InMemoryBudgetStateStore store = store((usage, plan) -> usd("40.00"));
+ ReservationId reservationId = reserve(
+ store,
+ pricingSnapshot(),
+ usd("60.00")
+ );
+ store.markInFlight(reservationId);
+
+ ReservationReconciliation reconciliation = store.commit(
+ command(reservationId)
+ );
+
+ assertThat(reconciliation.requestId()).isEqualTo("request-1");
+ assertThat(store.reservationForAccounting(reservationId).idempotencyKey())
+ .isEqualTo(new IdempotencyKey("deduplication-1"));
+ }
+
+ @Test
+ @DisplayName("late actual도 예약 시점 가격으로 한 번 계산해 pending을 정산한다")
+ void calculatesLateActualOnceAndReconcilesPendingReservation() {
+ AtomicInteger calculationCount = new AtomicInteger();
+ InMemoryBudgetStateStore store = store((usage, plan) -> {
+ calculationCount.incrementAndGet();
+ return usd("40.00");
+ });
+ ReservationId reservationId = reserve(
+ store,
+ pricingSnapshot(),
+ usd("60.00")
+ );
+ store.markInFlight(reservationId);
+ store.markReconciliationRequired(reservationId);
+
+ ReservationReconciliation reconciliation = store.reconcileLateActual(
+ command(reservationId)
+ );
+
+ assertThat(calculationCount).hasValue(1);
+ assertThat(reconciliation.transition().previousState())
+ .isEqualTo(RECONCILIATION_REQUIRED);
+ assertThat(reconciliation.transition().resultingState()).isEqualTo(COMMITTED);
+ assertThat(store.snapshot(KEY, LIMIT).pendingReconciliationLiability())
+ .isEqualTo(usd("0.00"));
+ assertThat(store.snapshot(KEY, LIMIT).committedCost()).isEqualTo(usd("40.00"));
+ }
+
+ @Test
+ @DisplayName("pricing snapshot이 없는 예약은 usage 정산 전에 거부한다")
+ void rejectsUsageReconciliationWithoutReservedPricingSnapshot() {
+ AtomicInteger calculationCount = new AtomicInteger();
+ InMemoryBudgetStateStore store = store((usage, plan) -> {
+ calculationCount.incrementAndGet();
+ return usd("40.00");
+ });
+ ReservationId reservationId = store.checkAndReserve(
+ KEY,
+ LIMIT,
+ usd("60.00"),
+ "request-1"
+ ).reservationId();
+ store.markInFlight(reservationId);
+ var before = store.snapshot(KEY, LIMIT);
+
+ assertThatThrownBy(() -> store.commit(command(reservationId)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("reservation does not contain a pricing snapshot");
+
+ assertThat(calculationCount).hasValue(0);
+ assertThat(store.snapshot(KEY, LIMIT)).isEqualTo(before);
+ }
+
+ @Test
+ @DisplayName("다른 request ID는 비용 계산 전에 거부한다")
+ void rejectsMismatchedRequestIdBeforeCostCalculation() {
+ AtomicInteger calculationCount = new AtomicInteger();
+ InMemoryBudgetStateStore store = store((usage, plan) -> {
+ calculationCount.incrementAndGet();
+ return usd("40.00");
+ });
+ ReservationId reservationId = reserve(
+ store,
+ pricingSnapshot(),
+ usd("60.00")
+ );
+ store.markInFlight(reservationId);
+ var before = store.snapshot(KEY, LIMIT);
+
+ assertThatThrownBy(
+ () -> store.commit(
+ new ActualUsageCommand(
+ "different-request",
+ "attempt-1",
+ reservationId,
+ TokenUsage.from(100, 50),
+ "gpt-4o-mini-response"
+ )
+ )
+ ).isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("requestId must match the reservation request");
+
+ assertThat(calculationCount).hasValue(0);
+ assertThat(store.snapshot(KEY, LIMIT)).isEqualTo(before);
+ }
+
+ @Test
+ @DisplayName("계산된 actual 통화가 예약 가격 통화와 다르면 상태를 변경하지 않는다")
+ void rejectsCalculatedCostWithUnexpectedCurrency() {
+ Currency eur = Currency.getInstance("EUR");
+ InMemoryBudgetStateStore store = store(
+ (usage, plan) -> Cost.of(new BigDecimal("40.00"), eur)
+ );
+ ReservationId reservationId = reserve(
+ store,
+ pricingSnapshot(),
+ usd("60.00")
+ );
+ store.markInFlight(reservationId);
+ var before = store.snapshot(KEY, LIMIT);
+
+ assertThatThrownBy(() -> store.commit(command(reservationId)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("calculated cost must use the pricing snapshot currency");
+
+ assertThat(store.snapshot(KEY, LIMIT)).isEqualTo(before);
+ }
+
+ @Test
+ @DisplayName("actual usage를 확인할 수 없으면 정산 명령으로 만들지 않는다")
+ void rejectsUnavailableActualUsage() {
+ assertThatThrownBy(
+ () -> new ActualUsageCommand(
+ "request-1",
+ "attempt-1",
+ new ReservationId("reservation-1"),
+ TokenUsage.unavailable(Map.of()),
+ "gpt-4o-mini-response"
+ )
+ ).isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("usage must be available for actual reconciliation");
+ }
+
+ @Test
+ @DisplayName("공개 factory는 예약 store와 같은 객체의 정산 진입점을 반환한다")
+ void exposesAccountingForTheSameReservationStore() {
+ AtomicInteger sequence = new AtomicInteger();
+ BudgetStateStore stateStore = LedgerBudgetComponents.inMemoryBudgetStateStore(
+ CLOCK,
+ () -> new ReservationId(
+ "reservation-" + sequence.incrementAndGet()
+ ),
+ (usage, plan) -> usd("40.00")
+ );
+ ReservationAccounting accounting =
+ LedgerBudgetComponents.reservationAccounting(stateStore);
+
+ assertThat(accounting).isSameAs(stateStore);
+ }
+
+ private static ReservationReconciliation reconcile(
+ String estimate,
+ String actual
+ ) {
+ InMemoryBudgetStateStore store = store((usage, plan) -> usd(actual));
+ ReservationId reservationId = reserve(
+ store,
+ pricingSnapshot(),
+ usd(estimate)
+ );
+ store.markInFlight(reservationId);
+ return store.commit(command(reservationId));
+ }
+
+ private static ActualUsageCommand command(ReservationId reservationId) {
+ return new ActualUsageCommand(
+ "request-1",
+ "attempt-1",
+ reservationId,
+ TokenUsage.from(100, 50),
+ "gpt-4o-mini-response"
+ );
+ }
+
+ private static ReservationId reserve(
+ InMemoryBudgetStateStore store,
+ PricingSnapshot snapshot,
+ Cost estimate
+ ) {
+ return store.checkAndReserve(
+ new BudgetReservationRequest(
+ KEY,
+ LIMIT,
+ estimate,
+ "request-1",
+ new IdempotencyKey("deduplication-1"),
+ snapshot.modelId(),
+ snapshot.pricingPolicyId(),
+ snapshot.catalogVersion(),
+ Optional.of(snapshot)
+ )
+ ).reservationId();
+ }
+
+ private static InMemoryBudgetStateStore store(CostCalculator calculator) {
+ AtomicInteger sequence = new AtomicInteger();
+ return new InMemoryBudgetStateStore(
+ CLOCK,
+ () -> new ReservationId("reservation-" + sequence.incrementAndGet()),
+ calculator
+ );
+ }
+
+ private static PricingSnapshot pricingSnapshot() {
+ return new PricingSnapshot(
+ "gpt-4o-mini-request",
+ "pricing-v1",
+ "catalog-v1",
+ CLOCK.instant(),
+ Map.of(
+ TokenType.PROMPT,
+ new BigDecimal("0.10"),
+ TokenType.COMPLETION,
+ new BigDecimal("0.20")
+ ),
+ USD
+ );
+ }
+
+ private static Cost usd(String amount) {
+ return Cost.of(new BigDecimal(amount), USD);
+ }
+}
diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/CostCalculator.java b/token-pilot-core/src/main/java/io/tokenpilot/core/CostCalculator.java
index c20da7c..ca5e8da 100644
--- a/token-pilot-core/src/main/java/io/tokenpilot/core/CostCalculator.java
+++ b/token-pilot-core/src/main/java/io/tokenpilot/core/CostCalculator.java
@@ -2,8 +2,11 @@
import io.tokenpilot.core.domain.Cost;
import io.tokenpilot.core.domain.PricingPlan;
+import io.tokenpilot.core.domain.PricingSnapshot;
import io.tokenpilot.core.domain.TokenUsage;
+import java.util.Objects;
+
/**
* 사용량과 가격 정책을 바탕으로 비용을 계산하는 인터페이스.
*/
@@ -15,4 +18,20 @@ public interface CostCalculator {
* @return 산출된 비용
*/
Cost calculate(TokenUsage usage, PricingPlan plan);
+
+ /**
+ * 예약 시점에 고정한 가격 snapshot으로 비용을 계산합니다.
+ */
+ default Cost calculate(TokenUsage usage, PricingSnapshot snapshot) {
+ Objects.requireNonNull(snapshot, "snapshot must not be null");
+ return calculate(
+ usage,
+ new PricingPlan(
+ snapshot.modelId(),
+ snapshot.pricingPolicyId(),
+ snapshot.rates(),
+ snapshot.currency()
+ )
+ );
+ }
}
From ad8b73189980ba12e407b1c791d3561b744b7ca2 Mon Sep 17 00:00:00 2001
From: rigu1
Date: Thu, 20 Aug 2026 04:24:36 +0900
Subject: [PATCH 06/13] =?UTF-8?q?refactor(budget):=20actual=20=EC=A0=95?=
=?UTF-8?q?=EC=82=B0=20=ED=9D=90=EB=A6=84=20=EB=8B=A8=EC=88=9C=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../internal/InMemoryBudgetStateStore.java | 86 ++++++++++++++++---
.../internal/ReservationUsageAccounting.java | 86 -------------------
.../ReservationReconciliationTest.java | 2 -
3 files changed, 76 insertions(+), 98 deletions(-)
delete mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationUsageAccounting.java
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
index 331fd3e..1754001 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
@@ -16,6 +16,7 @@
import io.tokenpilot.budget.ReservationTransition;
import io.tokenpilot.core.CostCalculator;
import io.tokenpilot.core.domain.Cost;
+import io.tokenpilot.core.domain.PricingSnapshot;
import io.tokenpilot.core.internal.LedgerComponents;
import java.time.Clock;
@@ -26,6 +27,7 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BiFunction;
import java.util.function.Supplier;
/**
@@ -43,7 +45,7 @@ public class InMemoryBudgetStateStore implements BudgetStateStore, ReservationAc
new ConcurrentHashMap<>();
private final Clock clock;
private final Supplier reservationIdGenerator;
- private final ReservationUsageAccounting usageAccounting;
+ private final CostCalculator costCalculator;
public InMemoryBudgetStateStore() {
this(
@@ -74,8 +76,9 @@ public InMemoryBudgetStateStore(
reservationIdGenerator,
"reservationIdGenerator must not be null"
);
- this.usageAccounting = new ReservationUsageAccounting(
- Objects.requireNonNull(costCalculator, "costCalculator must not be null")
+ this.costCalculator = Objects.requireNonNull(
+ costCalculator,
+ "costCalculator must not be null"
);
}
@@ -208,10 +211,8 @@ ReservationTransition commitCost(ReservationId reservationId, Cost actualCost) {
@Override
public ReservationReconciliation commit(ActualUsageCommand command) {
- Objects.requireNonNull(command, "command must not be null");
- return usageAccounting.commit(
+ return reconcileUsage(
command,
- reservationForAccounting(command.reservationId()),
this::commitCost
);
}
@@ -270,10 +271,8 @@ ReservationTransition reconcileLateActualCost(
@Override
public ReservationReconciliation reconcileLateActual(ActualUsageCommand command) {
- Objects.requireNonNull(command, "command must not be null");
- return usageAccounting.reconcileLateActual(
+ return reconcileUsage(
command,
- reservationForAccounting(command.reservationId()),
this::reconcileLateActualCost
);
}
@@ -327,7 +326,74 @@ private void validateCurrency(Cost limit, Cost amount) {
}
}
- BudgetReservation reservationForAccounting(ReservationId reservationId) {
+ private ReservationReconciliation reconcileUsage(
+ ActualUsageCommand command,
+ BiFunction transition
+ ) {
+ Objects.requireNonNull(command, "command must not be null");
+ Objects.requireNonNull(transition, "transition must not be null");
+ BudgetReservation reservation = reservationForAccounting(
+ command.reservationId()
+ );
+ if (!reservation.belongsTo(command.requestId())) {
+ throw new IllegalArgumentException(
+ "requestId must match the reservation request"
+ );
+ }
+
+ PricingSnapshot snapshot = reservation.pricingSnapshot().orElseThrow(
+ () -> new IllegalStateException(
+ "reservation does not contain a pricing snapshot"
+ )
+ );
+ Cost actualCost = calculateActualCost(command, snapshot);
+ ReservationTransition appliedTransition = transition.apply(
+ reservation.id(),
+ actualCost
+ );
+ return reconciliation(
+ command,
+ reservation,
+ snapshot,
+ actualCost,
+ appliedTransition
+ );
+ }
+
+ private Cost calculateActualCost(
+ ActualUsageCommand command,
+ PricingSnapshot snapshot
+ ) {
+ Cost actualCost = costCalculator.calculate(command.usage(), snapshot);
+ if (!snapshot.currency().equals(actualCost.currency())) {
+ throw new IllegalStateException(
+ "calculated cost must use the pricing snapshot currency"
+ );
+ }
+ return actualCost;
+ }
+
+ private static ReservationReconciliation reconciliation(
+ ActualUsageCommand command,
+ BudgetReservation reservation,
+ PricingSnapshot snapshot,
+ Cost actualCost,
+ ReservationTransition transition
+ ) {
+ return new ReservationReconciliation(
+ command.requestId(),
+ command.attemptId(),
+ reservation.id(),
+ reservation.key(),
+ command.responseModelId(),
+ snapshot,
+ reservation.amount(),
+ actualCost,
+ transition
+ );
+ }
+
+ private BudgetReservation reservationForAccounting(ReservationId reservationId) {
Bucket bucket = bucketFor(reservationId);
synchronized (bucket) {
return accountingState(bucket, reservationId).reservation();
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationUsageAccounting.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationUsageAccounting.java
deleted file mode 100644
index cd02ec4..0000000
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationUsageAccounting.java
+++ /dev/null
@@ -1,86 +0,0 @@
-package io.tokenpilot.budget.internal;
-
-import io.tokenpilot.budget.ActualUsageCommand;
-import io.tokenpilot.budget.BudgetReservation;
-import io.tokenpilot.budget.ReservationId;
-import io.tokenpilot.budget.ReservationReconciliation;
-import io.tokenpilot.budget.ReservationTransition;
-import io.tokenpilot.core.CostCalculator;
-import io.tokenpilot.core.domain.Cost;
-import io.tokenpilot.core.domain.PricingSnapshot;
-
-import java.util.Objects;
-import java.util.function.BiFunction;
-
-/**
- * actual usage를 예약 시점 가격으로 계산하고 저장소 상태 전이에 연결합니다.
- */
-final class ReservationUsageAccounting {
-
- private final CostCalculator costCalculator;
-
- ReservationUsageAccounting(CostCalculator costCalculator) {
- this.costCalculator = Objects.requireNonNull(
- costCalculator,
- "costCalculator must not be null"
- );
- }
-
- ReservationReconciliation commit(
- ActualUsageCommand command,
- BudgetReservation reservation,
- BiFunction transition
- ) {
- return reconcile(command, reservation, transition);
- }
-
- ReservationReconciliation reconcileLateActual(
- ActualUsageCommand command,
- BudgetReservation reservation,
- BiFunction transition
- ) {
- return reconcile(command, reservation, transition);
- }
-
- private ReservationReconciliation reconcile(
- ActualUsageCommand command,
- BudgetReservation reservation,
- BiFunction transition
- ) {
- Objects.requireNonNull(command, "command must not be null");
- Objects.requireNonNull(reservation, "reservation must not be null");
- Objects.requireNonNull(transition, "transition must not be null");
- if (!reservation.belongsTo(command.requestId())) {
- throw new IllegalArgumentException(
- "requestId must match the reservation request"
- );
- }
-
- PricingSnapshot snapshot = reservation.pricingSnapshot().orElseThrow(
- () -> new IllegalStateException(
- "reservation does not contain a pricing snapshot"
- )
- );
- Cost actualCost = costCalculator.calculate(command.usage(), snapshot);
- if (!snapshot.currency().equals(actualCost.currency())) {
- throw new IllegalStateException(
- "calculated cost must use the pricing snapshot currency"
- );
- }
- ReservationTransition appliedTransition = transition.apply(
- reservation.id(),
- actualCost
- );
- return new ReservationReconciliation(
- command.requestId(),
- command.attemptId(),
- reservation.id(),
- reservation.key(),
- command.responseModelId(),
- snapshot,
- reservation.amount(),
- actualCost,
- appliedTransition
- );
- }
-}
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
index 08bcae8..cfbd81f 100644
--- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
@@ -154,8 +154,6 @@ void reconcilesWhenRequestIdDiffersFromIdempotencyKey() {
);
assertThat(reconciliation.requestId()).isEqualTo("request-1");
- assertThat(store.reservationForAccounting(reservationId).idempotencyKey())
- .isEqualTo(new IdempotencyKey("deduplication-1"));
}
@Test
From 0c093574fb2266b005e3231940ab0ec572f51bc3 Mon Sep 17 00:00:00 2001
From: rigu1
Date: Thu, 20 Aug 2026 12:08:23 +0900
Subject: [PATCH 07/13] =?UTF-8?q?feat(budget):=20actual=20=EC=A0=95?=
=?UTF-8?q?=EC=82=B0=20=EA=B2=B0=EA=B3=BC=EC=99=80=20=ED=9A=8C=EA=B3=84=20?=
=?UTF-8?q?=EC=82=AC=EC=9C=A0=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20=ED=8C=90?=
=?UTF-8?q?=EB=B3=84=EC=9D=84=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../tokenpilot/budget/BudgetReservation.java | 10 +-
.../budget/BudgetReservationRequest.java | 61 +++-
.../budget/ReservationAccounting.java | 45 ++-
.../budget/ReservationAccountingReason.java | 13 +
.../budget/ReservationActualTokens.java | 45 +++
.../budget/ReservationReconciliation.java | 31 +-
.../budget/ReservationTokenEstimate.java | 22 ++
.../internal/ActualUsageFingerprint.java | 28 ++
.../budget/internal/AppliedCommit.java | 69 +++-
.../budget/internal/AppliedRelease.java | 22 +-
.../budget/internal/CommitType.java | 7 +
.../internal/InMemoryBudgetStateStore.java | 316 ++++++++++++++----
.../budget/internal/ReleaseType.java | 7 +
.../internal/ReservationAccountingState.java | 91 ++++-
.../internal/ReservationAccountingTest.java | 69 ++++
.../ReservationReconciliationTest.java | 191 ++++++++++-
16 files changed, 920 insertions(+), 107 deletions(-)
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingReason.java
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationActualTokens.java
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTokenEstimate.java
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ActualUsageFingerprint.java
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/CommitType.java
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReleaseType.java
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java
index ffc32f8..443660e 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java
@@ -21,6 +21,7 @@ public record BudgetReservation(
String pricingPolicyId,
String catalogVersion,
Optional pricingSnapshot,
+ Optional tokenEstimate,
ReservationState state,
Instant createdAt
) {
@@ -47,6 +48,10 @@ public record BudgetReservation(
pricingSnapshot,
"pricingSnapshot must not be null"
);
+ tokenEstimate = Objects.requireNonNull(
+ tokenEstimate,
+ "tokenEstimate must not be null"
+ );
if (pricingSnapshot.isPresent()) {
PricingSnapshot snapshot = pricingSnapshot.orElseThrow();
requireSnapshotText(modelId, snapshot.modelId(), "modelId");
@@ -96,6 +101,7 @@ public BudgetReservation(
pricingPolicyId,
catalogVersion,
Optional.empty(),
+ Optional.empty(),
state,
createdAt
);
@@ -118,6 +124,7 @@ public static BudgetReservation reserved(
request.pricingPolicyId(),
request.catalogVersion(),
request.pricingSnapshot(),
+ request.tokenEstimate(),
ReservationState.RESERVED,
createdAt
);
@@ -132,7 +139,8 @@ public boolean matches(BudgetReservationRequest request) {
&& Objects.equals(modelId, request.modelId())
&& Objects.equals(pricingPolicyId, request.pricingPolicyId())
&& Objects.equals(catalogVersion, request.catalogVersion())
- && pricingSnapshot.equals(request.pricingSnapshot());
+ && pricingSnapshot.equals(request.pricingSnapshot())
+ && tokenEstimate.equals(request.tokenEstimate());
}
/** 이 예약이 지정한 provider 요청에 속하는지 확인합니다. */
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java
index 3e62c3f..bd7a433 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java
@@ -22,7 +22,8 @@ public record BudgetReservationRequest(
String modelId,
String pricingPolicyId,
String catalogVersion,
- Optional pricingSnapshot
+ Optional pricingSnapshot,
+ Optional tokenEstimate
) {
public BudgetReservationRequest {
@@ -41,6 +42,10 @@ public record BudgetReservationRequest(
pricingSnapshot,
"pricingSnapshot must not be null"
);
+ tokenEstimate = Objects.requireNonNull(
+ tokenEstimate,
+ "tokenEstimate must not be null"
+ );
if (pricingSnapshot.isPresent()) {
PricingSnapshot snapshot = pricingSnapshot.orElseThrow();
modelId = snapshotText(modelId, snapshot.modelId(), "modelId");
@@ -85,6 +90,7 @@ public BudgetReservationRequest(
modelId,
pricingPolicyId,
catalogVersion,
+ Optional.empty(),
Optional.empty()
);
}
@@ -113,7 +119,8 @@ public BudgetReservationRequest(
modelId,
pricingPolicyId,
catalogVersion,
- pricingSnapshot
+ pricingSnapshot,
+ Optional.empty()
);
}
@@ -136,6 +143,7 @@ public BudgetReservationRequest(
null,
null,
null,
+ Optional.empty(),
Optional.empty()
);
}
@@ -162,10 +170,59 @@ public BudgetReservationRequest(
modelId,
pricingPolicyId,
catalogVersion,
+ Optional.empty(),
+ Optional.empty()
+ );
+ }
+
+ public BudgetReservationRequest(
+ BudgetKey key,
+ Cost limit,
+ Cost safeUpperBoundCost,
+ String requestId,
+ IdempotencyKey idempotencyKey,
+ String modelId,
+ String pricingPolicyId,
+ String catalogVersion,
+ Optional pricingSnapshot
+ ) {
+ this(
+ key,
+ limit,
+ safeUpperBoundCost,
+ requestId,
+ idempotencyKey,
+ modelId,
+ pricingPolicyId,
+ catalogVersion,
+ pricingSnapshot,
Optional.empty()
);
}
+ public BudgetReservationRequest(
+ BudgetKey key,
+ Cost limit,
+ Cost safeUpperBoundCost,
+ String requestId,
+ IdempotencyKey idempotencyKey,
+ PricingSnapshot pricingSnapshot,
+ ReservationTokenEstimate tokenEstimate
+ ) {
+ this(
+ key,
+ limit,
+ safeUpperBoundCost,
+ requestId,
+ idempotencyKey,
+ pricingSnapshot.modelId(),
+ pricingSnapshot.pricingPolicyId(),
+ pricingSnapshot.catalogVersion(),
+ Optional.of(pricingSnapshot),
+ Optional.of(tokenEstimate)
+ );
+ }
+
private static String optionalText(String value, String name) {
if (value != null && value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
index d17b977..72b6918 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
@@ -9,20 +9,57 @@ public interface ReservationAccounting {
ReservationTransition markInFlight(ReservationId reservationId);
/** provider 호출 전에 사용하지 않은 예약을 해제합니다. */
- ReservationTransition releaseBeforeDispatch(ReservationId reservationId);
+ default ReservationTransition releaseBeforeDispatch(ReservationId reservationId) {
+ return release(
+ reservationId,
+ ReservationAccountingReason.CANCELLED_BEFORE_DISPATCH
+ );
+ }
/** provider가 미과금을 확인한 진행 중 예약을 해제합니다. */
- ReservationTransition releaseConfirmedUnbilled(ReservationId reservationId);
+ default ReservationTransition releaseConfirmedUnbilled(ReservationId reservationId) {
+ return release(
+ reservationId,
+ ReservationAccountingReason.PROVIDER_CONFIRMED_UNBILLED
+ );
+ }
+
+ ReservationTransition release(
+ ReservationId reservationId,
+ ReservationAccountingReason reason
+ );
/** provider actual usage를 예약 시점 가격으로 계산하여 확정합니다. */
ReservationReconciliation commit(ActualUsageCommand command);
/** actual을 확보하지 못한 예약을 정산 대기로 전환합니다. */
- ReservationTransition markReconciliationRequired(ReservationId reservationId);
+ default ReservationTransition markReconciliationRequired(
+ ReservationId reservationId
+ ) {
+ return markReconciliationRequired(
+ reservationId,
+ ReservationAccountingReason.ACTUAL_USAGE_UNAVAILABLE
+ );
+ }
+
+ ReservationTransition markReconciliationRequired(
+ ReservationId reservationId,
+ ReservationAccountingReason reason
+ );
/** 늦게 도착한 provider actual usage를 예약 시점 가격으로 계산하여 확정합니다. */
ReservationReconciliation reconcileLateActual(ActualUsageCommand command);
/** 후속 정산할 수 없는 pending 예약을 명시적으로 상각합니다. */
- ReservationTransition writeOff(ReservationId reservationId);
+ default ReservationTransition writeOff(ReservationId reservationId) {
+ return writeOff(
+ reservationId,
+ ReservationAccountingReason.MANUAL_WRITE_OFF
+ );
+ }
+
+ ReservationTransition writeOff(
+ ReservationId reservationId,
+ ReservationAccountingReason reason
+ );
}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingReason.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingReason.java
new file mode 100644
index 0000000..af50e02
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingReason.java
@@ -0,0 +1,13 @@
+package io.tokenpilot.budget;
+
+/** 회계 명령과 정산 결과에 사용하는 제한된 사유입니다. */
+public enum ReservationAccountingReason {
+ ACTUAL_USAGE_REPORTED,
+ LATE_ACTUAL_USAGE_REPORTED,
+ ACTUAL_USAGE_UNAVAILABLE,
+ CALLBACK_TIMED_OUT,
+ CANCELLED_BEFORE_DISPATCH,
+ PROVIDER_CONFIRMED_UNBILLED,
+ MANUAL_WRITE_OFF,
+ ACTUAL_USAGE_UNRECOVERABLE
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationActualTokens.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationActualTokens.java
new file mode 100644
index 0000000..d5e377b
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationActualTokens.java
@@ -0,0 +1,45 @@
+package io.tokenpilot.budget;
+
+import io.tokenpilot.core.domain.TokenUsage;
+import io.tokenpilot.core.domain.TokenUsageDetails;
+import io.tokenpilot.core.domain.UsageSource;
+
+import java.util.Map;
+import java.util.Objects;
+
+/** Provider actual usage에서 무제한 metadata를 제외한 회계용 token 결과입니다. */
+public record ReservationActualTokens(
+ long inputTokens,
+ long outputTokens,
+ TokenUsageDetails details,
+ UsageSource source
+) {
+
+ public ReservationActualTokens {
+ if (inputTokens < 0 || outputTokens < 0) {
+ throw new IllegalArgumentException("actual tokens must be non-negative");
+ }
+ Objects.requireNonNull(details, "details must not be null");
+ Objects.requireNonNull(source, "source must not be null");
+ if (source == UsageSource.UNAVAILABLE) {
+ throw new IllegalArgumentException(
+ "actual token source must be available"
+ );
+ }
+ new TokenUsage(inputTokens, outputTokens, details, source, Map.of());
+ }
+
+ public static ReservationActualTokens from(TokenUsage usage) {
+ Objects.requireNonNull(usage, "usage must not be null");
+ return new ReservationActualTokens(
+ usage.inputTokens(),
+ usage.outputTokens(),
+ usage.details(),
+ usage.source()
+ );
+ }
+
+ public long totalTokens() {
+ return Math.addExact(inputTokens, outputTokens);
+ }
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationReconciliation.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationReconciliation.java
index 14f3889..be34bea 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationReconciliation.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationReconciliation.java
@@ -17,9 +17,13 @@ public record ReservationReconciliation(
BudgetKey budgetKey,
String responseModelId,
PricingSnapshot pricingSnapshot,
+ ReservationTokenEstimate tokenEstimate,
+ ReservationActualTokens actualTokens,
Cost estimate,
Cost actual,
- ReservationTransition transition
+ boolean overLimit,
+ ReservationTransition transition,
+ ReservationAccountingReason reason
) {
public ReservationReconciliation {
@@ -29,9 +33,12 @@ public record ReservationReconciliation(
Objects.requireNonNull(budgetKey, "budgetKey must not be null");
responseModelId = requireText(responseModelId, "responseModelId");
Objects.requireNonNull(pricingSnapshot, "pricingSnapshot must not be null");
+ Objects.requireNonNull(tokenEstimate, "tokenEstimate must not be null");
+ Objects.requireNonNull(actualTokens, "actualTokens must not be null");
Objects.requireNonNull(estimate, "estimate must not be null");
Objects.requireNonNull(actual, "actual must not be null");
Objects.requireNonNull(transition, "transition must not be null");
+ Objects.requireNonNull(reason, "reason must not be null");
if (!estimate.currency().equals(actual.currency())) {
throw new IllegalArgumentException(
"estimate and actual must use the same currency"
@@ -64,6 +71,28 @@ public BigDecimal delta() {
return actual.value().subtract(estimate.value());
}
+ public long inputTokenDelta() {
+ return Math.subtractExact(
+ actualTokens.inputTokens(),
+ tokenEstimate.inputEstimatedTokens()
+ );
+ }
+
+ public long outputTokenDelta() {
+ return Math.subtractExact(
+ actualTokens.outputTokens(),
+ tokenEstimate.reservedOutputTokens()
+ );
+ }
+
+ public long totalTokenDelta() {
+ long estimatedTotal = Math.addExact(
+ tokenEstimate.inputEstimatedTokens(),
+ tokenEstimate.reservedOutputTokens()
+ );
+ return Math.subtractExact(actualTokens.totalTokens(), estimatedTotal);
+ }
+
/** estimate와 actual이 사용하는 통화입니다. */
public Currency currency() {
return actual.currency();
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTokenEstimate.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTokenEstimate.java
new file mode 100644
index 0000000..3e59e05
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTokenEstimate.java
@@ -0,0 +1,22 @@
+package io.tokenpilot.budget;
+
+/** 예약 비용의 근거가 된 호출 전 token estimate입니다. */
+public record ReservationTokenEstimate(
+ long inputEstimatedTokens,
+ long inputSafeUpperBoundTokens,
+ long reservedOutputTokens
+) {
+
+ public ReservationTokenEstimate {
+ if (inputEstimatedTokens < 0
+ || inputSafeUpperBoundTokens < 0
+ || reservedOutputTokens < 0) {
+ throw new IllegalArgumentException("estimated tokens must be non-negative");
+ }
+ if (inputSafeUpperBoundTokens < inputEstimatedTokens) {
+ throw new IllegalArgumentException(
+ "inputSafeUpperBoundTokens must be greater than or equal to inputEstimatedTokens"
+ );
+ }
+ }
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ActualUsageFingerprint.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ActualUsageFingerprint.java
new file mode 100644
index 0000000..2f08d41
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ActualUsageFingerprint.java
@@ -0,0 +1,28 @@
+package io.tokenpilot.budget.internal;
+
+import io.tokenpilot.budget.ActualUsageCommand;
+import io.tokenpilot.budget.ReservationActualTokens;
+import io.tokenpilot.budget.ReservationId;
+
+/** 중복 actual callback을 민감하거나 무제한인 metadata 없이 식별합니다. */
+record ActualUsageFingerprint(
+ String requestId,
+ String attemptId,
+ ReservationId reservationId,
+ ReservationActualTokens actualTokens,
+ String responseModelId
+) {
+
+ static ActualUsageFingerprint from(
+ ActualUsageCommand command,
+ ReservationActualTokens actualTokens
+ ) {
+ return new ActualUsageFingerprint(
+ command.requestId(),
+ command.attemptId(),
+ command.reservationId(),
+ actualTokens,
+ command.responseModelId()
+ );
+ }
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedCommit.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedCommit.java
index 64b8355..5e4c937 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedCommit.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedCommit.java
@@ -6,6 +6,7 @@
import io.tokenpilot.core.domain.Cost;
import java.util.Objects;
+import java.util.Optional;
import static io.tokenpilot.budget.AccountingTransitionStatus.CONFLICT;
import static io.tokenpilot.budget.AccountingTransitionStatus.REUSED;
@@ -13,39 +14,81 @@
/**
* 이미 적용된 commit의 종류, actual과 재호출 판단을 보관합니다.
*/
-record AppliedCommit(Kind kind, Cost actualCost) {
+record AppliedCommit(
+ CommitType type,
+ Cost actualCost,
+ boolean overLimit,
+ Optional fingerprint
+) {
AppliedCommit {
- Objects.requireNonNull(kind, "kind must not be null");
+ Objects.requireNonNull(type, "type must not be null");
Objects.requireNonNull(actualCost, "actualCost must not be null");
+ fingerprint = Objects.requireNonNull(
+ fingerprint,
+ "fingerprint must not be null"
+ );
}
- static AppliedCommit direct(Cost actualCost) {
- return new AppliedCommit(Kind.DIRECT, actualCost);
+ static AppliedCommit costOnly(CommitType type, Cost actualCost) {
+ return new AppliedCommit(
+ type,
+ actualCost,
+ false,
+ Optional.empty()
+ );
}
- static AppliedCommit lateActual(Cost actualCost) {
- return new AppliedCommit(Kind.LATE_ACTUAL, actualCost);
+ static AppliedCommit fromCallback(
+ CommitType type,
+ Cost actualCost,
+ boolean overLimit,
+ ActualUsageFingerprint fingerprint
+ ) {
+ return new AppliedCommit(
+ type,
+ actualCost,
+ overLimit,
+ Optional.of(fingerprint)
+ );
+ }
+
+ boolean matches(
+ CommitType requestedType,
+ ActualUsageFingerprint requestedFingerprint
+ ) {
+ return type == requestedType
+ && fingerprint.filter(requestedFingerprint::equals).isPresent();
}
ReservationTransition evaluate(
- Kind requestedKind,
+ CommitType requestedType,
ReservationState state,
- Cost requestedActualCost
+ Cost requestedActualCost,
+ Optional requestedFingerprint
) {
- Objects.requireNonNull(requestedKind, "requestedKind must not be null");
+ Objects.requireNonNull(requestedType, "requestedType must not be null");
Objects.requireNonNull(state, "state must not be null");
Objects.requireNonNull(requestedActualCost, "requestedActualCost must not be null");
+ Objects.requireNonNull(
+ requestedFingerprint,
+ "requestedFingerprint must not be null"
+ );
- AccountingTransitionStatus status = kind == requestedKind
+ AccountingTransitionStatus status = type == requestedType
&& actualCost.equals(requestedActualCost)
+ && matchesFingerprintWhenProvided(requestedFingerprint)
? REUSED
: CONFLICT;
return ReservationTransition.unchanged(state, status);
}
- enum Kind {
- DIRECT,
- LATE_ACTUAL
+ private boolean matchesFingerprintWhenProvided(
+ Optional requestedFingerprint
+ ) {
+ if (fingerprint.isEmpty() || requestedFingerprint.isEmpty()) {
+ return true;
+ }
+ return fingerprint.equals(requestedFingerprint);
}
}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedRelease.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedRelease.java
index 6daf30b..fdc6c8d 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedRelease.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedRelease.java
@@ -12,30 +12,28 @@
/**
* 이미 적용된 release의 종류와 재호출 판단을 보관합니다.
*/
-record AppliedRelease(Kind kind) {
+record AppliedRelease(ReleaseType type) {
AppliedRelease {
- Objects.requireNonNull(kind, "kind must not be null");
+ Objects.requireNonNull(type, "type must not be null");
}
static AppliedRelease beforeDispatch() {
- return new AppliedRelease(Kind.BEFORE_DISPATCH);
+ return new AppliedRelease(ReleaseType.BEFORE_DISPATCH);
}
static AppliedRelease confirmedUnbilled() {
- return new AppliedRelease(Kind.CONFIRMED_UNBILLED);
+ return new AppliedRelease(ReleaseType.CONFIRMED_UNBILLED);
}
- ReservationTransition evaluate(Kind requestedKind, ReservationState state) {
- Objects.requireNonNull(requestedKind, "requestedKind must not be null");
+ ReservationTransition evaluate(
+ ReleaseType requestedType,
+ ReservationState state
+ ) {
+ Objects.requireNonNull(requestedType, "requestedType must not be null");
Objects.requireNonNull(state, "state must not be null");
- AccountingTransitionStatus status = kind == requestedKind ? REUSED : CONFLICT;
+ AccountingTransitionStatus status = type == requestedType ? REUSED : CONFLICT;
return ReservationTransition.unchanged(state, status);
}
-
- enum Kind {
- BEFORE_DISPATCH,
- CONFIRMED_UNBILLED
- }
}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/CommitType.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/CommitType.java
new file mode 100644
index 0000000..e1f780a
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/CommitType.java
@@ -0,0 +1,7 @@
+package io.tokenpilot.budget.internal;
+
+/** Actual 비용이 확정된 경로입니다. */
+enum CommitType {
+ DIRECT,
+ LATE_ACTUAL
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
index 1754001..eac4c35 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
@@ -1,6 +1,7 @@
package io.tokenpilot.budget.internal;
import io.tokenpilot.budget.ActualUsageCommand;
+import io.tokenpilot.budget.AccountingTransitionStatus;
import io.tokenpilot.budget.BudgetKey;
import io.tokenpilot.budget.BudgetReservation;
import io.tokenpilot.budget.BudgetReservationRequest;
@@ -9,11 +10,14 @@
import io.tokenpilot.budget.BudgetStateStore;
import io.tokenpilot.budget.IdempotencyKey;
import io.tokenpilot.budget.ReservationAccounting;
+import io.tokenpilot.budget.ReservationAccountingReason;
+import io.tokenpilot.budget.ReservationActualTokens;
import io.tokenpilot.budget.ReservationId;
import io.tokenpilot.budget.ReservationReconciliation;
import io.tokenpilot.budget.ReservationState;
import io.tokenpilot.budget.ReservationStateMachine;
import io.tokenpilot.budget.ReservationTransition;
+import io.tokenpilot.budget.ReservationTokenEstimate;
import io.tokenpilot.core.CostCalculator;
import io.tokenpilot.core.domain.Cost;
import io.tokenpilot.core.domain.PricingSnapshot;
@@ -23,11 +27,11 @@
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicReference;
-import java.util.function.BiFunction;
import java.util.function.Supplier;
/**
@@ -131,7 +135,30 @@ public ReservationTransition markInFlight(ReservationId reservationId) {
}
@Override
- public ReservationTransition releaseBeforeDispatch(ReservationId reservationId) {
+ public ReservationTransition release(
+ ReservationId reservationId,
+ ReservationAccountingReason reason
+ ) {
+ Objects.requireNonNull(reason, "reason must not be null");
+ return switch (reason) {
+ case CANCELLED_BEFORE_DISPATCH -> releaseBeforeDispatch(
+ reservationId,
+ reason
+ );
+ case PROVIDER_CONFIRMED_UNBILLED -> releaseConfirmedUnbilled(
+ reservationId,
+ reason
+ );
+ default -> throw new IllegalArgumentException(
+ "reason is not valid for release"
+ );
+ };
+ }
+
+ private ReservationTransition releaseBeforeDispatch(
+ ReservationId reservationId,
+ ReservationAccountingReason reason
+ ) {
Bucket bucket = bucketFor(reservationId);
synchronized (bucket) {
ReservationAccountingState accountingState = accountingState(
@@ -158,8 +185,10 @@ public ReservationTransition releaseBeforeDispatch(ReservationId reservationId)
}
}
- @Override
- public ReservationTransition releaseConfirmedUnbilled(ReservationId reservationId) {
+ private ReservationTransition releaseConfirmedUnbilled(
+ ReservationId reservationId,
+ ReservationAccountingReason reason
+ ) {
Bucket bucket = bucketFor(reservationId);
synchronized (bucket) {
ReservationAccountingState accountingState = accountingState(
@@ -187,49 +216,37 @@ public ReservationTransition releaseConfirmedUnbilled(ReservationId reservationI
}
ReservationTransition commitCost(ReservationId reservationId, Cost actualCost) {
- Objects.requireNonNull(actualCost, "actualCost must not be null");
- Bucket bucket = bucketFor(reservationId);
- synchronized (bucket) {
- ReservationAccountingState accountingState = accountingState(
- bucket,
- reservationId
- );
- ReservationTransition transition = accountingState.evaluateCommit(actualCost);
- if (!transition.status().isApplied()) {
- return transition;
- }
-
- commitActiveReservation(
- bucket,
- accountingState,
- actualCost,
- transition.resultingState()
- );
- return transition;
- }
+ return applyCost(
+ reservationId,
+ actualCost,
+ CommitType.DIRECT,
+ Optional.empty()
+ ).transition();
}
@Override
public ReservationReconciliation commit(ActualUsageCommand command) {
return reconcileUsage(
command,
- this::commitCost
+ CommitType.DIRECT,
+ ReservationAccountingReason.ACTUAL_USAGE_REPORTED
);
}
@Override
- public ReservationTransition markReconciliationRequired(ReservationId reservationId) {
+ public ReservationTransition markReconciliationRequired(
+ ReservationId reservationId,
+ ReservationAccountingReason reason
+ ) {
+ requireReconciliationRequiredReason(reason);
Bucket bucket = bucketFor(reservationId);
synchronized (bucket) {
ReservationAccountingState accountingState = accountingState(
bucket,
reservationId
);
- BudgetReservation reservation = accountingState.reservation();
ReservationTransition transition =
- ReservationStateMachine.markReconciliationRequired(
- reservation.state()
- );
+ accountingState.evaluateReconciliationRequired();
if (!transition.status().isApplied()) {
return transition;
}
@@ -246,39 +263,87 @@ public ReservationTransition markReconciliationRequired(ReservationId reservatio
ReservationTransition reconcileLateActualCost(
ReservationId reservationId,
Cost actualCost
+ ) {
+ return applyCost(
+ reservationId,
+ actualCost,
+ CommitType.LATE_ACTUAL,
+ Optional.empty()
+ ).transition();
+ }
+
+ private AccountingTransitionOutcome applyCost(
+ ReservationId reservationId,
+ Cost actualCost,
+ CommitType type,
+ Optional fingerprint
) {
Objects.requireNonNull(actualCost, "actualCost must not be null");
+ Objects.requireNonNull(type, "type must not be null");
+ Objects.requireNonNull(fingerprint, "fingerprint must not be null");
Bucket bucket = bucketFor(reservationId);
synchronized (bucket) {
ReservationAccountingState accountingState = accountingState(
bucket,
reservationId
);
- ReservationTransition transition = accountingState.evaluateLateActual(actualCost);
- if (!transition.status().isApplied()) {
- return transition;
- }
-
- commitPendingReservation(
+ return applyCostInBucket(
bucket,
accountingState,
actualCost,
- transition.resultingState()
+ type,
+ fingerprint
);
- return transition;
}
}
+ private AccountingTransitionOutcome applyCostInBucket(
+ Bucket bucket,
+ ReservationAccountingState accountingState,
+ Cost actualCost,
+ CommitType type,
+ Optional fingerprint
+ ) {
+ ReservationTransition transition = type == CommitType.DIRECT
+ ? accountingState.evaluateCommit(actualCost, fingerprint)
+ : accountingState.evaluateLateActual(actualCost, fingerprint);
+ if (!transition.status().isApplied()) {
+ return outcome(bucket, transition);
+ }
+
+ boolean overLimit = type == CommitType.DIRECT
+ ? commitActiveReservation(
+ bucket,
+ accountingState,
+ actualCost,
+ transition.resultingState(),
+ fingerprint
+ )
+ : commitPendingReservation(
+ bucket,
+ accountingState,
+ actualCost,
+ transition.resultingState(),
+ fingerprint
+ );
+ return new AccountingTransitionOutcome(transition, overLimit);
+ }
+
@Override
public ReservationReconciliation reconcileLateActual(ActualUsageCommand command) {
return reconcileUsage(
command,
- this::reconcileLateActualCost
+ CommitType.LATE_ACTUAL,
+ ReservationAccountingReason.LATE_ACTUAL_USAGE_REPORTED
);
}
@Override
- public ReservationTransition writeOff(ReservationId reservationId) {
+ public ReservationTransition writeOff(
+ ReservationId reservationId,
+ ReservationAccountingReason reason
+ ) {
+ requireWriteOffReason(reason);
Bucket bucket = bucketFor(reservationId);
synchronized (bucket) {
ReservationAccountingState accountingState = accountingState(
@@ -328,13 +393,29 @@ private void validateCurrency(Cost limit, Cost amount) {
private ReservationReconciliation reconcileUsage(
ActualUsageCommand command,
- BiFunction transition
+ CommitType type,
+ ReservationAccountingReason reason
) {
Objects.requireNonNull(command, "command must not be null");
- Objects.requireNonNull(transition, "transition must not be null");
- BudgetReservation reservation = reservationForAccounting(
+ Objects.requireNonNull(type, "type must not be null");
+ Objects.requireNonNull(reason, "reason must not be null");
+ Bucket bucket = bucketFor(command.reservationId());
+ synchronized (bucket) {
+ return reconcileUsageInBucket(bucket, command, type, reason);
+ }
+ }
+
+ private ReservationReconciliation reconcileUsageInBucket(
+ Bucket bucket,
+ ActualUsageCommand command,
+ CommitType type,
+ ReservationAccountingReason reason
+ ) {
+ ReservationAccountingState accountingState = accountingState(
+ bucket,
command.reservationId()
);
+ BudgetReservation reservation = accountingState.reservation();
if (!reservation.belongsTo(command.requestId())) {
throw new IllegalArgumentException(
"requestId must match the reservation request"
@@ -346,17 +427,58 @@ private ReservationReconciliation reconcileUsage(
"reservation does not contain a pricing snapshot"
)
);
+ ReservationTokenEstimate tokenEstimate = reservation.tokenEstimate().orElseThrow(
+ () -> new IllegalStateException(
+ "reservation does not contain a token estimate"
+ )
+ );
+ ReservationActualTokens actualTokens = ReservationActualTokens.from(
+ command.usage()
+ );
+ ActualUsageFingerprint fingerprint = ActualUsageFingerprint.from(
+ command,
+ actualTokens
+ );
+ Optional reusedCommit =
+ accountingState.reusedCommit(type, fingerprint);
+ if (reusedCommit.isPresent()) {
+ AppliedCommit appliedCommit = reusedCommit.orElseThrow();
+ AccountingTransitionOutcome reused = new AccountingTransitionOutcome(
+ ReservationTransition.unchanged(
+ reservation.state(),
+ AccountingTransitionStatus.REUSED
+ ),
+ appliedCommit.overLimit()
+ );
+ return reconciliation(
+ command,
+ reservation,
+ snapshot,
+ tokenEstimate,
+ actualTokens,
+ appliedCommit.actualCost(),
+ reused,
+ reason
+ );
+ }
+
Cost actualCost = calculateActualCost(command, snapshot);
- ReservationTransition appliedTransition = transition.apply(
- reservation.id(),
- actualCost
+ AccountingTransitionOutcome outcome = applyCostInBucket(
+ bucket,
+ accountingState,
+ actualCost,
+ type,
+ Optional.of(fingerprint)
);
return reconciliation(
command,
reservation,
snapshot,
+ tokenEstimate,
+ actualTokens,
actualCost,
- appliedTransition
+ outcome,
+ reason
);
}
@@ -377,8 +499,11 @@ private static ReservationReconciliation reconciliation(
ActualUsageCommand command,
BudgetReservation reservation,
PricingSnapshot snapshot,
+ ReservationTokenEstimate tokenEstimate,
+ ReservationActualTokens actualTokens,
Cost actualCost,
- ReservationTransition transition
+ AccountingTransitionOutcome outcome,
+ ReservationAccountingReason reason
) {
return new ReservationReconciliation(
command.requestId(),
@@ -387,17 +512,22 @@ private static ReservationReconciliation reconciliation(
reservation.key(),
command.responseModelId(),
snapshot,
+ tokenEstimate,
+ actualTokens,
reservation.amount(),
actualCost,
- transition
+ outcome.overLimit(),
+ outcome.transition(),
+ reason
);
}
- private BudgetReservation reservationForAccounting(ReservationId reservationId) {
- Bucket bucket = bucketFor(reservationId);
- synchronized (bucket) {
- return accountingState(bucket, reservationId).reservation();
- }
+ private static AccountingTransitionOutcome outcome(
+ Bucket bucket,
+ ReservationTransition transition
+ ) {
+ boolean overLimit = bucket.effectiveUsage().compareTo(bucket.limit) > 0;
+ return new AccountingTransitionOutcome(transition, overLimit);
}
private ReservationId reserveOrReturnExisting(
@@ -551,11 +681,12 @@ private ReservationTransition updateState(
}
}
- private void commitActiveReservation(
+ private boolean commitActiveReservation(
Bucket bucket,
ReservationAccountingState accountingState,
Cost actualCost,
- ReservationState resultingState
+ ReservationState resultingState,
+ Optional fingerprint
) {
BudgetReservation reservation = accountingState.reservation();
Cost remainingReserved = subtract(
@@ -565,13 +696,27 @@ private void commitActiveReservation(
Cost committed = bucket.committedCost.add(actualCost);
BudgetReservation updated = withState(reservation, resultingState);
+ boolean overLimit = remainingReserved
+ .add(bucket.pendingReconciliationLiability)
+ .add(committed)
+ .compareTo(bucket.limit) > 0;
+ ReservationAccountingState updatedAccountingState = fingerprint
+ .map(appliedFingerprint -> accountingState.committed(
+ updated,
+ actualCost,
+ overLimit,
+ appliedFingerprint
+ ))
+ .orElseGet(() -> accountingState.committed(updated, actualCost));
+
bucket.activeReservedCost = remainingReserved;
bucket.committedCost = committed;
replaceReservationSnapshot(
bucket,
accountingState,
- accountingState.committed(updated, actualCost)
+ updatedAccountingState
);
+ return overLimit;
}
private void releaseActiveReservation(
@@ -617,11 +762,12 @@ private void moveActiveReservationToPending(
);
}
- private void commitPendingReservation(
+ private boolean commitPendingReservation(
Bucket bucket,
ReservationAccountingState accountingState,
Cost actualCost,
- ReservationState resultingState
+ ReservationState resultingState,
+ Optional fingerprint
) {
BudgetReservation reservation = accountingState.reservation();
Cost remainingPending = subtract(
@@ -631,13 +777,32 @@ private void commitPendingReservation(
Cost committed = bucket.committedCost.add(actualCost);
BudgetReservation updated = withState(reservation, resultingState);
+ boolean overLimit = bucket.activeReservedCost
+ .add(remainingPending)
+ .add(committed)
+ .compareTo(bucket.limit) > 0;
+ ReservationAccountingState updatedAccountingState = fingerprint
+ .map(appliedFingerprint -> accountingState.lateActualCommitted(
+ updated,
+ actualCost,
+ overLimit,
+ appliedFingerprint
+ ))
+ .orElseGet(
+ () -> accountingState.lateActualCommitted(
+ updated,
+ actualCost
+ )
+ );
+
bucket.pendingReconciliationLiability = remainingPending;
bucket.committedCost = committed;
replaceReservationSnapshot(
bucket,
accountingState,
- accountingState.lateActualCommitted(updated, actualCost)
+ updatedAccountingState
);
+ return overLimit;
}
private void writeOffPendingReservation(
@@ -660,6 +825,26 @@ private void writeOffPendingReservation(
);
}
+ private static void requireReconciliationRequiredReason(
+ ReservationAccountingReason reason
+ ) {
+ Objects.requireNonNull(reason, "reason must not be null");
+ if (reason != ReservationAccountingReason.ACTUAL_USAGE_UNAVAILABLE
+ && reason != ReservationAccountingReason.CALLBACK_TIMED_OUT) {
+ throw new IllegalArgumentException(
+ "reason is not valid for markReconciliationRequired"
+ );
+ }
+ }
+
+ private static void requireWriteOffReason(ReservationAccountingReason reason) {
+ Objects.requireNonNull(reason, "reason must not be null");
+ if (reason != ReservationAccountingReason.MANUAL_WRITE_OFF
+ && reason != ReservationAccountingReason.ACTUAL_USAGE_UNRECOVERABLE) {
+ throw new IllegalArgumentException("reason is not valid for writeOff");
+ }
+ }
+
private void replaceReservationSnapshot(
Bucket bucket,
ReservationAccountingState previous,
@@ -710,6 +895,7 @@ private static BudgetReservation withState(
reservation.pricingPolicyId(),
reservation.catalogVersion(),
reservation.pricingSnapshot(),
+ reservation.tokenEstimate(),
state,
reservation.createdAt()
);
@@ -767,4 +953,14 @@ private Set activeReservationIds() {
}
}
+ private record AccountingTransitionOutcome(
+ ReservationTransition transition,
+ boolean overLimit
+ ) {
+
+ private AccountingTransitionOutcome {
+ Objects.requireNonNull(transition, "transition must not be null");
+ }
+ }
+
}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReleaseType.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReleaseType.java
new file mode 100644
index 0000000..79f5fe1
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReleaseType.java
@@ -0,0 +1,7 @@
+package io.tokenpilot.budget.internal;
+
+/** 예약 금액이 해제된 경로입니다. */
+enum ReleaseType {
+ BEFORE_DISPATCH,
+ CONFIRMED_UNBILLED
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java
index 79373e9..80fbaee 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java
@@ -11,6 +11,7 @@
import static io.tokenpilot.budget.AccountingTransitionStatus.CONFLICT;
import static io.tokenpilot.budget.AccountingTransitionStatus.CURRENCY_MISMATCH;
import static io.tokenpilot.budget.AccountingTransitionStatus.REUSED;
+import static io.tokenpilot.budget.ReservationState.RECONCILIATION_REQUIRED;
import static io.tokenpilot.budget.ReservationState.WRITTEN_OFF;
/**
@@ -56,8 +57,12 @@ BudgetReservation reservation() {
return reservation;
}
- ReservationTransition evaluateCommit(Cost actualCost) {
+ ReservationTransition evaluateCommit(
+ Cost actualCost,
+ Optional fingerprint
+ ) {
Objects.requireNonNull(actualCost, "actualCost must not be null");
+ Objects.requireNonNull(fingerprint, "fingerprint must not be null");
if (hasDifferentCurrency(actualCost)) {
return ReservationTransition.unchanged(
reservation.state(),
@@ -69,15 +74,20 @@ ReservationTransition evaluateCommit(Cost actualCost) {
}
return appliedCommit
.map(commit -> commit.evaluate(
- AppliedCommit.Kind.DIRECT,
+ CommitType.DIRECT,
reservation.state(),
- actualCost
+ actualCost,
+ fingerprint
))
.orElseGet(() -> ReservationStateMachine.commit(reservation.state()));
}
- ReservationTransition evaluateLateActual(Cost actualCost) {
+ ReservationTransition evaluateLateActual(
+ Cost actualCost,
+ Optional fingerprint
+ ) {
Objects.requireNonNull(actualCost, "actualCost must not be null");
+ Objects.requireNonNull(fingerprint, "fingerprint must not be null");
if (hasDifferentCurrency(actualCost)) {
return ReservationTransition.unchanged(
reservation.state(),
@@ -92,9 +102,10 @@ ReservationTransition evaluateLateActual(Cost actualCost) {
}
return appliedCommit
.map(commit -> commit.evaluate(
- AppliedCommit.Kind.LATE_ACTUAL,
+ CommitType.LATE_ACTUAL,
reservation.state(),
- actualCost
+ actualCost,
+ fingerprint
))
.orElseGet(
() -> ReservationStateMachine.reconcileLateActual(
@@ -109,7 +120,7 @@ ReservationTransition evaluateReleaseBeforeDispatch() {
}
return appliedRelease
.map(release -> release.evaluate(
- AppliedRelease.Kind.BEFORE_DISPATCH,
+ ReleaseType.BEFORE_DISPATCH,
reservation.state()
))
.orElseGet(() -> ReservationStateMachine.release(reservation.state()));
@@ -121,7 +132,7 @@ ReservationTransition evaluateConfirmedUnbilledRelease() {
}
return appliedRelease
.map(release -> release.evaluate(
- AppliedRelease.Kind.CONFIRMED_UNBILLED,
+ ReleaseType.CONFIRMED_UNBILLED,
reservation.state()
))
.orElseGet(
@@ -144,6 +155,18 @@ ReservationTransition evaluateWriteOff() {
return ReservationStateMachine.writeOff(reservation.state());
}
+ ReservationTransition evaluateReconciliationRequired() {
+ if (reservation.state() == RECONCILIATION_REQUIRED) {
+ return ReservationTransition.unchanged(
+ reservation.state(),
+ REUSED
+ );
+ }
+ return ReservationStateMachine.markReconciliationRequired(
+ reservation.state()
+ );
+ }
+
ReservationAccountingState withReservation(BudgetReservation updatedReservation) {
return new ReservationAccountingState(
updatedReservation,
@@ -178,7 +201,7 @@ ReservationAccountingState committed(
) {
return new ReservationAccountingState(
updatedReservation,
- Optional.of(AppliedCommit.direct(actualCost)),
+ Optional.of(AppliedCommit.costOnly(CommitType.DIRECT, actualCost)),
Optional.empty()
);
}
@@ -189,7 +212,55 @@ ReservationAccountingState lateActualCommitted(
) {
return new ReservationAccountingState(
updatedReservation,
- Optional.of(AppliedCommit.lateActual(actualCost)),
+ Optional.of(AppliedCommit.costOnly(
+ CommitType.LATE_ACTUAL,
+ actualCost
+ )),
+ Optional.empty()
+ );
+ }
+
+ Optional reusedCommit(
+ CommitType type,
+ ActualUsageFingerprint fingerprint
+ ) {
+ Objects.requireNonNull(type, "type must not be null");
+ Objects.requireNonNull(fingerprint, "fingerprint must not be null");
+ return appliedCommit.filter(commit -> commit.matches(type, fingerprint));
+ }
+
+ ReservationAccountingState committed(
+ BudgetReservation updatedReservation,
+ Cost actualCost,
+ boolean overLimit,
+ ActualUsageFingerprint fingerprint
+ ) {
+ return new ReservationAccountingState(
+ updatedReservation,
+ Optional.of(AppliedCommit.fromCallback(
+ CommitType.DIRECT,
+ actualCost,
+ overLimit,
+ fingerprint
+ )),
+ Optional.empty()
+ );
+ }
+
+ ReservationAccountingState lateActualCommitted(
+ BudgetReservation updatedReservation,
+ Cost actualCost,
+ boolean overLimit,
+ ActualUsageFingerprint fingerprint
+ ) {
+ return new ReservationAccountingState(
+ updatedReservation,
+ Optional.of(AppliedCommit.fromCallback(
+ CommitType.LATE_ACTUAL,
+ actualCost,
+ overLimit,
+ fingerprint
+ )),
Optional.empty()
);
}
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java
index 1c03eff..3988940 100644
--- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java
@@ -4,6 +4,7 @@
import io.tokenpilot.budget.BudgetSnapshot;
import io.tokenpilot.budget.BudgetWindow;
import io.tokenpilot.budget.ReservationAccounting;
+import io.tokenpilot.budget.ReservationAccountingReason;
import io.tokenpilot.budget.ReservationId;
import io.tokenpilot.budget.ReservationTransition;
import io.tokenpilot.core.domain.Cost;
@@ -301,6 +302,41 @@ void movesEstimateToPendingWhenActualIsUnavailable() {
assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("60.00"));
}
+ @Test
+ @DisplayName("정산 대기 명령을 반복해도 pending은 한 번만 반영한다")
+ void reusesRepeatedPendingCommand() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.markReconciliationRequired(reservationId);
+
+ ReservationTransition repeated = store.markReconciliationRequired(
+ reservationId,
+ ReservationAccountingReason.CALLBACK_TIMED_OUT
+ );
+
+ assertThat(repeated.status()).isEqualTo(REUSED);
+ assertThat(store.snapshot(KEY, LIMIT).pendingReconciliationLiability())
+ .isEqualTo(usd("60.00"));
+ }
+
+ @Test
+ @DisplayName("명령과 관계없는 정산 대기 사유는 상태 변경 전에 거부한다")
+ void rejectsReasonUnrelatedToPendingReconciliation() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+
+ assertThatThrownBy(
+ () -> store.markReconciliationRequired(
+ reservationId,
+ ReservationAccountingReason.MANUAL_WRITE_OFF
+ )
+ ).isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("reason is not valid for markReconciliationRequired");
+
+ assertThat(store.snapshot(KEY, LIMIT).activeReservedCost())
+ .isEqualTo(usd("60.00"));
+ }
+
@Test
@DisplayName("actual이 0이면 비용을 확정하고 actual을 모르면 estimate를 pending으로 유지한다")
void distinguishesZeroActualFromUnavailableActual() {
@@ -487,6 +523,39 @@ void releasesReservationBeforeDispatch() {
assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
}
+ @Test
+ @DisplayName("release는 제한된 사유로 호출 시점의 해제 행위를 선택한다")
+ void releasesAccordingToBoundedReason() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserve(store, usd("60.00"));
+
+ ReservationTransition transition = store.release(
+ reservationId,
+ ReservationAccountingReason.CANCELLED_BEFORE_DISPATCH
+ );
+
+ assertThat(transition)
+ .isEqualTo(ReservationTransition.applied(RESERVED, RELEASED));
+ }
+
+ @Test
+ @DisplayName("release와 관계없는 제한 사유는 상태 변경 전에 거부한다")
+ void rejectsReasonUnrelatedToRelease() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserve(store, usd("60.00"));
+
+ assertThatThrownBy(
+ () -> store.release(
+ reservationId,
+ ReservationAccountingReason.ACTUAL_USAGE_UNAVAILABLE
+ )
+ ).isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("reason is not valid for release");
+
+ assertThat(store.snapshot(KEY, LIMIT).activeReservedCost())
+ .isEqualTo(usd("60.00"));
+ }
+
@Test
@DisplayName("호출 전 release를 반복하면 기존 해제를 재사용한다")
void reusesReleaseBeforeDispatch() {
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
index cfbd81f..68d6244 100644
--- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
@@ -8,7 +8,10 @@
import io.tokenpilot.budget.IdempotencyKey;
import io.tokenpilot.budget.ReservationId;
import io.tokenpilot.budget.ReservationAccounting;
+import io.tokenpilot.budget.ReservationAccountingReason;
+import io.tokenpilot.budget.ReservationActualTokens;
import io.tokenpilot.budget.ReservationReconciliation;
+import io.tokenpilot.budget.ReservationTokenEstimate;
import io.tokenpilot.core.CostCalculator;
import io.tokenpilot.core.domain.Cost;
import io.tokenpilot.core.domain.PricingPlan;
@@ -31,6 +34,8 @@
import static io.tokenpilot.budget.ReservationState.COMMITTED;
import static io.tokenpilot.budget.ReservationState.IN_FLIGHT;
import static io.tokenpilot.budget.ReservationState.RECONCILIATION_REQUIRED;
+import static io.tokenpilot.budget.AccountingTransitionStatus.CONFLICT;
+import static io.tokenpilot.budget.AccountingTransitionStatus.REUSED;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -48,6 +53,8 @@ class ReservationReconciliationTest {
Instant.parse("2026-08-15T12:34:56Z"),
ZoneOffset.UTC
);
+ private static final ReservationTokenEstimate TOKEN_ESTIMATE =
+ new ReservationTokenEstimate(90, 100, 50);
@Test
@DisplayName("예약 시점 pricing snapshot으로 actual 비용을 한 번만 계산한다")
@@ -82,6 +89,8 @@ void calculatesActualCostOnceWithReservedPricingSnapshot() {
assertThat(calculatedPlan.get().currency()).isEqualTo(snapshot.currency());
assertThat(reconciliation.transition().previousState()).isEqualTo(IN_FLIGHT);
assertThat(reconciliation.transition().resultingState()).isEqualTo(COMMITTED);
+ assertThat(reconciliation.reason())
+ .isEqualTo(ReservationAccountingReason.ACTUAL_USAGE_REPORTED);
}
@Test
@@ -108,6 +117,22 @@ void returnsPositiveDeltaWhenActualIsAboveEstimate() {
assertThat(reconciliation.delta()).isEqualByComparingTo("20.00");
}
+ @Test
+ @DisplayName("actual 정산으로 limit을 넘으면 결과에 초과 상태를 남긴다")
+ void reportsOverLimitWhenActualExceedsBudgetLimit() {
+ ReservationReconciliation reconciliation = reconcile("60.00", "120.00");
+
+ assertThat(reconciliation.overLimit()).isTrue();
+ }
+
+ @Test
+ @DisplayName("actual 정산이 limit과 같으면 초과로 표시하지 않는다")
+ void doesNotReportOverLimitAtExactBudgetLimit() {
+ ReservationReconciliation reconciliation = reconcile("60.00", "100.00");
+
+ assertThat(reconciliation.overLimit()).isFalse();
+ }
+
@Test
@DisplayName("정산 결과는 요청과 시도, 예약, 모델, pricing 정보를 연결한다")
void correlatesRequestReservationModelsAndPricing() {
@@ -133,9 +158,17 @@ void correlatesRequestReservationModelsAndPricing() {
.isEqualTo(snapshot.catalogVersion());
assertThat(reconciliation.estimate()).isEqualTo(usd("60.00"));
assertThat(reconciliation.actual()).isEqualTo(usd("40.00"));
+ assertThat(reconciliation.tokenEstimate()).isEqualTo(TOKEN_ESTIMATE);
+ assertThat(reconciliation.actualTokens())
+ .isEqualTo(ReservationActualTokens.from(TokenUsage.from(100, 50)));
+ assertThat(reconciliation.inputTokenDelta()).isEqualTo(10);
+ assertThat(reconciliation.outputTokenDelta()).isZero();
+ assertThat(reconciliation.totalTokenDelta()).isEqualTo(10);
assertThat(reconciliation.currency()).isEqualTo(USD);
assertThat(reconciliation.transition().previousState()).isEqualTo(IN_FLIGHT);
assertThat(reconciliation.transition().resultingState()).isEqualTo(COMMITTED);
+ assertThat(reconciliation.reason())
+ .isEqualTo(ReservationAccountingReason.ACTUAL_USAGE_REPORTED);
}
@Test
@@ -180,6 +213,8 @@ void calculatesLateActualOnceAndReconcilesPendingReservation() {
assertThat(reconciliation.transition().previousState())
.isEqualTo(RECONCILIATION_REQUIRED);
assertThat(reconciliation.transition().resultingState()).isEqualTo(COMMITTED);
+ assertThat(reconciliation.reason())
+ .isEqualTo(ReservationAccountingReason.LATE_ACTUAL_USAGE_REPORTED);
assertThat(store.snapshot(KEY, LIMIT).pendingReconciliationLiability())
.isEqualTo(usd("0.00"));
assertThat(store.snapshot(KEY, LIMIT).committedCost()).isEqualTo(usd("40.00"));
@@ -210,6 +245,39 @@ void rejectsUsageReconciliationWithoutReservedPricingSnapshot() {
assertThat(store.snapshot(KEY, LIMIT)).isEqualTo(before);
}
+ @Test
+ @DisplayName("token estimate가 없는 예약은 비용 계산 전에 정산을 거부한다")
+ void rejectsUsageReconciliationWithoutTokenEstimate() {
+ AtomicInteger calculationCount = new AtomicInteger();
+ InMemoryBudgetStateStore store = store((usage, plan) -> {
+ calculationCount.incrementAndGet();
+ return usd("40.00");
+ });
+ PricingSnapshot snapshot = pricingSnapshot();
+ ReservationId reservationId = store.checkAndReserve(
+ new BudgetReservationRequest(
+ KEY,
+ LIMIT,
+ usd("60.00"),
+ "request-1",
+ new IdempotencyKey("deduplication-1"),
+ snapshot.modelId(),
+ snapshot.pricingPolicyId(),
+ snapshot.catalogVersion(),
+ Optional.of(snapshot)
+ )
+ ).reservationId();
+ store.markInFlight(reservationId);
+ var before = store.snapshot(KEY, LIMIT);
+
+ assertThatThrownBy(() -> store.commit(command(reservationId)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("reservation does not contain a token estimate");
+
+ assertThat(calculationCount).hasValue(0);
+ assertThat(store.snapshot(KEY, LIMIT)).isEqualTo(before);
+ }
+
@Test
@DisplayName("다른 request ID는 비용 계산 전에 거부한다")
void rejectsMismatchedRequestIdBeforeCostCalculation() {
@@ -280,6 +348,123 @@ void rejectsUnavailableActualUsage() {
.hasMessage("usage must be available for actual reconciliation");
}
+ @Test
+ @DisplayName("동일 commit callback은 actual 비용을 다시 계산하지 않는다")
+ void doesNotRecalculateCostForDuplicateCommitCallback() {
+ AtomicInteger calculationCount = new AtomicInteger();
+ InMemoryBudgetStateStore store = store((usage, plan) -> {
+ calculationCount.incrementAndGet();
+ return usd("40.00");
+ });
+ ReservationId reservationId = reserve(
+ store,
+ pricingSnapshot(),
+ usd("60.00")
+ );
+ store.markInFlight(reservationId);
+
+ ReservationReconciliation first = store.commit(command(reservationId));
+ ReservationReconciliation duplicate = store.commit(command(reservationId));
+
+ assertThat(calculationCount).hasValue(1);
+ assertThat(first.transition().status().isApplied()).isTrue();
+ assertThat(duplicate.transition().status()).isEqualTo(REUSED);
+ assertThat(duplicate.actual()).isEqualTo(first.actual());
+ assertThat(duplicate.overLimit()).isEqualTo(first.overLimit());
+ }
+
+ @Test
+ @DisplayName("동일 late actual callback은 actual 비용을 다시 계산하지 않는다")
+ void doesNotRecalculateCostForDuplicateLateActualCallback() {
+ AtomicInteger calculationCount = new AtomicInteger();
+ InMemoryBudgetStateStore store = store((usage, plan) -> {
+ calculationCount.incrementAndGet();
+ return usd("40.00");
+ });
+ ReservationId reservationId = reserve(
+ store,
+ pricingSnapshot(),
+ usd("60.00")
+ );
+ store.markInFlight(reservationId);
+ store.markReconciliationRequired(reservationId);
+
+ ReservationReconciliation first = store.reconcileLateActual(
+ command(reservationId)
+ );
+ ReservationReconciliation duplicate = store.reconcileLateActual(
+ command(reservationId)
+ );
+
+ assertThat(calculationCount).hasValue(1);
+ assertThat(first.transition().status().isApplied()).isTrue();
+ assertThat(duplicate.transition().status()).isEqualTo(REUSED);
+ }
+
+ @Test
+ @DisplayName("다른 callback payload는 비용을 계산한 뒤 기존 commit과 충돌한다")
+ void calculatesChangedCallbackBeforeReportingConflict() {
+ AtomicInteger calculationCount = new AtomicInteger();
+ InMemoryBudgetStateStore store = store((usage, plan) -> {
+ calculationCount.incrementAndGet();
+ return usage.inputTokens() == 100 ? usd("40.00") : usd("50.00");
+ });
+ ReservationId reservationId = reserve(
+ store,
+ pricingSnapshot(),
+ usd("60.00")
+ );
+ store.markInFlight(reservationId);
+ store.commit(command(reservationId));
+
+ ReservationReconciliation conflict = store.commit(
+ new ActualUsageCommand(
+ "request-1",
+ "attempt-1",
+ reservationId,
+ TokenUsage.from(101, 50),
+ "gpt-4o-mini-response"
+ )
+ );
+
+ assertThat(calculationCount).hasValue(2);
+ assertThat(conflict.transition().status()).isEqualTo(CONFLICT);
+ assertThat(store.snapshot(KEY, LIMIT).committedCost())
+ .isEqualTo(usd("40.00"));
+ }
+
+ @Test
+ @DisplayName("비용이 같아도 callback payload가 다르면 기존 commit과 충돌한다")
+ void reportsConflictWhenChangedCallbackHasSameCost() {
+ AtomicInteger calculationCount = new AtomicInteger();
+ InMemoryBudgetStateStore store = store((usage, plan) -> {
+ calculationCount.incrementAndGet();
+ return usd("40.00");
+ });
+ ReservationId reservationId = reserve(
+ store,
+ pricingSnapshot(),
+ usd("60.00")
+ );
+ store.markInFlight(reservationId);
+ store.commit(command(reservationId));
+
+ ReservationReconciliation conflict = store.commit(
+ new ActualUsageCommand(
+ "request-1",
+ "attempt-1",
+ reservationId,
+ TokenUsage.from(101, 50),
+ "gpt-4o-mini-response"
+ )
+ );
+
+ assertThat(calculationCount).hasValue(2);
+ assertThat(conflict.transition().status()).isEqualTo(CONFLICT);
+ assertThat(store.snapshot(KEY, LIMIT).committedCost())
+ .isEqualTo(usd("40.00"));
+ }
+
@Test
@DisplayName("공개 factory는 예약 store와 같은 객체의 정산 진입점을 반환한다")
void exposesAccountingForTheSameReservationStore() {
@@ -333,10 +518,8 @@ private static ReservationId reserve(
estimate,
"request-1",
new IdempotencyKey("deduplication-1"),
- snapshot.modelId(),
- snapshot.pricingPolicyId(),
- snapshot.catalogVersion(),
- Optional.of(snapshot)
+ snapshot,
+ TOKEN_ESTIMATE
)
).reservationId();
}
From aa9ef7791139be761e8aa715d23df01ee0563886 Mon Sep 17 00:00:00 2001
From: rigu1
Date: Thu, 20 Aug 2026 12:21:10 +0900
Subject: [PATCH 08/13] =?UTF-8?q?feat(budget):=20actual=20=EC=A0=95?=
=?UTF-8?q?=EC=82=B0=20=EA=B3=84=EC=95=BD=EA=B3=BC=20=EC=A4=91=EB=B3=B5=20?=
=?UTF-8?q?=EC=97=86=EB=8A=94=20=ED=9A=8C=EA=B3=84=20=EC=9D=B4=EB=B2=A4?=
=?UTF-8?q?=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../budget/ReservationAccountingEvent.java | 16 ++++
.../budget/ReservationAccountingListener.java | 8 ++
.../internal/InMemoryBudgetStateStore.java | 42 +++++++++-
.../internal/LedgerBudgetComponents.java | 16 ++++
.../ReservationReconciliationTest.java | 77 ++++++++++++++++++-
5 files changed, 157 insertions(+), 2 deletions(-)
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingEvent.java
create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingListener.java
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingEvent.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingEvent.java
new file mode 100644
index 0000000..00438d9
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingEvent.java
@@ -0,0 +1,16 @@
+package io.tokenpilot.budget;
+
+import java.util.Objects;
+
+/** 새롭게 적용된 예약 정산을 전달하는 회계 이벤트입니다. */
+public record ReservationAccountingEvent(
+ ReservationReconciliation reconciliation
+) {
+
+ public ReservationAccountingEvent {
+ Objects.requireNonNull(
+ reconciliation,
+ "reconciliation must not be null"
+ );
+ }
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingListener.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingListener.java
new file mode 100644
index 0000000..57d206b
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingListener.java
@@ -0,0 +1,8 @@
+package io.tokenpilot.budget;
+
+/** 예약 정산 이벤트를 수신하는 framework-independent 계약입니다. */
+@FunctionalInterface
+public interface ReservationAccountingListener {
+
+ void onCommitted(ReservationAccountingEvent event);
+}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
index eac4c35..359831a 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
@@ -10,6 +10,8 @@
import io.tokenpilot.budget.BudgetStateStore;
import io.tokenpilot.budget.IdempotencyKey;
import io.tokenpilot.budget.ReservationAccounting;
+import io.tokenpilot.budget.ReservationAccountingEvent;
+import io.tokenpilot.budget.ReservationAccountingListener;
import io.tokenpilot.budget.ReservationAccountingReason;
import io.tokenpilot.budget.ReservationActualTokens;
import io.tokenpilot.budget.ReservationId;
@@ -25,6 +27,7 @@
import java.time.Clock;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -50,6 +53,7 @@ public class InMemoryBudgetStateStore implements BudgetStateStore, ReservationAc
private final Clock clock;
private final Supplier reservationIdGenerator;
private final CostCalculator costCalculator;
+ private final List accountingListeners;
public InMemoryBudgetStateStore() {
this(
@@ -74,6 +78,15 @@ public InMemoryBudgetStateStore(
Clock clock,
Supplier reservationIdGenerator,
CostCalculator costCalculator
+ ) {
+ this(clock, reservationIdGenerator, costCalculator, List.of());
+ }
+
+ public InMemoryBudgetStateStore(
+ Clock clock,
+ Supplier reservationIdGenerator,
+ CostCalculator costCalculator,
+ List accountingListeners
) {
this.clock = Objects.requireNonNull(clock, "clock must not be null");
this.reservationIdGenerator = Objects.requireNonNull(
@@ -84,6 +97,12 @@ public InMemoryBudgetStateStore(
costCalculator,
"costCalculator must not be null"
);
+ this.accountingListeners = List.copyOf(
+ Objects.requireNonNull(
+ accountingListeners,
+ "accountingListeners must not be null"
+ )
+ );
}
@Override
@@ -400,9 +419,30 @@ private ReservationReconciliation reconcileUsage(
Objects.requireNonNull(type, "type must not be null");
Objects.requireNonNull(reason, "reason must not be null");
Bucket bucket = bucketFor(command.reservationId());
+ ReservationReconciliation reconciliation;
synchronized (bucket) {
- return reconcileUsageInBucket(bucket, command, type, reason);
+ reconciliation = reconcileUsageInBucket(
+ bucket,
+ command,
+ type,
+ reason
+ );
}
+ publishAccountingEvent(reconciliation);
+ return reconciliation;
+ }
+
+ private void publishAccountingEvent(
+ ReservationReconciliation reconciliation
+ ) {
+ if (!reconciliation.transition().status().isApplied()
+ || accountingListeners.isEmpty()) {
+ return;
+ }
+ ReservationAccountingEvent event = new ReservationAccountingEvent(
+ reconciliation
+ );
+ accountingListeners.forEach(listener -> listener.onCommitted(event));
}
private ReservationReconciliation reconcileUsageInBucket(
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java
index e41eaa1..d0380c1 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java
@@ -4,10 +4,12 @@
import io.tokenpilot.budget.BudgetPolicy;
import io.tokenpilot.budget.BudgetStateStore;
import io.tokenpilot.budget.ReservationAccounting;
+import io.tokenpilot.budget.ReservationAccountingListener;
import io.tokenpilot.budget.ReservationId;
import io.tokenpilot.core.CostCalculator;
import java.time.Clock;
+import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
@@ -42,6 +44,20 @@ public static BudgetStateStore inMemoryBudgetStateStore(
);
}
+ public static BudgetStateStore inMemoryBudgetStateStore(
+ Clock clock,
+ Supplier reservationIdGenerator,
+ CostCalculator costCalculator,
+ List accountingListeners
+ ) {
+ return new InMemoryBudgetStateStore(
+ clock,
+ reservationIdGenerator,
+ costCalculator,
+ accountingListeners
+ );
+ }
+
/**
* 예약을 생성한 store와 동일한 객체의 회계 명령 진입점을 반환합니다.
*/
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
index 68d6244..4896ccc 100644
--- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
@@ -8,6 +8,7 @@
import io.tokenpilot.budget.IdempotencyKey;
import io.tokenpilot.budget.ReservationId;
import io.tokenpilot.budget.ReservationAccounting;
+import io.tokenpilot.budget.ReservationAccountingEvent;
import io.tokenpilot.budget.ReservationAccountingReason;
import io.tokenpilot.budget.ReservationActualTokens;
import io.tokenpilot.budget.ReservationReconciliation;
@@ -26,6 +27,8 @@
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Currency;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
@@ -465,6 +468,78 @@ void reportsConflictWhenChangedCallbackHasSameCost() {
.isEqualTo(usd("40.00"));
}
+ @Test
+ @DisplayName("새롭게 적용된 commit만 회계 이벤트를 한 번 생성한다")
+ void publishesAccountingEventOnlyForNewlyAppliedCommit() {
+ List events = new ArrayList<>();
+ AtomicInteger sequence = new AtomicInteger();
+ BudgetStateStore stateStore = LedgerBudgetComponents.inMemoryBudgetStateStore(
+ CLOCK,
+ () -> new ReservationId(
+ "reservation-" + sequence.incrementAndGet()
+ ),
+ (usage, plan) -> usd("40.00"),
+ List.of(events::add)
+ );
+ ReservationAccounting accounting =
+ LedgerBudgetComponents.reservationAccounting(stateStore);
+ ReservationId reservationId = reserve(
+ stateStore,
+ pricingSnapshot(),
+ usd("60.00")
+ );
+ accounting.markInFlight(reservationId);
+
+ ReservationReconciliation applied = accounting.commit(command(reservationId));
+ accounting.commit(command(reservationId));
+ accounting.commit(
+ new ActualUsageCommand(
+ "request-1",
+ "attempt-1",
+ reservationId,
+ TokenUsage.from(101, 50),
+ "gpt-4o-mini-response"
+ )
+ );
+
+ assertThat(events).containsExactly(
+ new ReservationAccountingEvent(applied)
+ );
+ }
+
+ @Test
+ @DisplayName("새롭게 적용된 late actual만 회계 이벤트를 한 번 생성한다")
+ void publishesAccountingEventOnlyForNewlyAppliedLateActual() {
+ List events = new ArrayList<>();
+ AtomicInteger sequence = new AtomicInteger();
+ BudgetStateStore stateStore = LedgerBudgetComponents.inMemoryBudgetStateStore(
+ CLOCK,
+ () -> new ReservationId(
+ "reservation-" + sequence.incrementAndGet()
+ ),
+ (usage, plan) -> usd("40.00"),
+ List.of(events::add)
+ );
+ ReservationAccounting accounting =
+ LedgerBudgetComponents.reservationAccounting(stateStore);
+ ReservationId reservationId = reserve(
+ stateStore,
+ pricingSnapshot(),
+ usd("60.00")
+ );
+ accounting.markInFlight(reservationId);
+ accounting.markReconciliationRequired(reservationId);
+
+ ReservationReconciliation applied = accounting.reconcileLateActual(
+ command(reservationId)
+ );
+ accounting.reconcileLateActual(command(reservationId));
+
+ assertThat(events).containsExactly(
+ new ReservationAccountingEvent(applied)
+ );
+ }
+
@Test
@DisplayName("공개 factory는 예약 store와 같은 객체의 정산 진입점을 반환한다")
void exposesAccountingForTheSameReservationStore() {
@@ -507,7 +582,7 @@ private static ActualUsageCommand command(ReservationId reservationId) {
}
private static ReservationId reserve(
- InMemoryBudgetStateStore store,
+ BudgetStateStore store,
PricingSnapshot snapshot,
Cost estimate
) {
From f75d848082fc41fe460b914ec4e8a6ae15421116 Mon Sep 17 00:00:00 2001
From: rigu1
Date: Thu, 20 Aug 2026 12:28:36 +0900
Subject: [PATCH 09/13] =?UTF-8?q?fix(budget):=20listener=20=EC=8B=A4?=
=?UTF-8?q?=ED=8C=A8=EA=B0=80=20=ED=9A=8C=EA=B3=84=20=EC=A0=95=EC=82=B0?=
=?UTF-8?q?=EC=97=90=20=EC=98=81=ED=96=A5=EC=9D=84=20=EC=A3=BC=EC=A7=80=20?=
=?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../internal/InMemoryBudgetStateStore.java | 15 +++-
.../ReservationReconciliationTest.java | 78 +++++++++++++++++++
2 files changed, 92 insertions(+), 1 deletion(-)
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
index 359831a..dfb3d71 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
@@ -442,7 +442,20 @@ private void publishAccountingEvent(
ReservationAccountingEvent event = new ReservationAccountingEvent(
reconciliation
);
- accountingListeners.forEach(listener -> listener.onCommitted(event));
+ for (ReservationAccountingListener listener : accountingListeners) {
+ notifyBestEffort(listener, event);
+ }
+ }
+
+ private static void notifyBestEffort(
+ ReservationAccountingListener listener,
+ ReservationAccountingEvent event
+ ) {
+ try {
+ listener.onCommitted(event);
+ } catch (RuntimeException ignored) {
+ // Listener 실패는 이미 적용된 회계 상태를 되돌리지 않습니다.
+ }
}
private ReservationReconciliation reconcileUsageInBucket(
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
index 4896ccc..4d77717 100644
--- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
@@ -540,6 +540,84 @@ void publishesAccountingEventOnlyForNewlyAppliedLateActual() {
);
}
+ @Test
+ @DisplayName("listener 실패는 commit을 되돌리지 않고 중복 callback에서 재시도하지 않는다")
+ void preservesCommitAndDoesNotRetryEventAfterListenerFailure() {
+ AtomicInteger deliveryCount = new AtomicInteger();
+ AtomicInteger sequence = new AtomicInteger();
+ BudgetStateStore stateStore = LedgerBudgetComponents.inMemoryBudgetStateStore(
+ CLOCK,
+ () -> new ReservationId(
+ "reservation-" + sequence.incrementAndGet()
+ ),
+ (usage, plan) -> usd("40.00"),
+ List.of(event -> {
+ deliveryCount.incrementAndGet();
+ throw new IllegalStateException("listener failed");
+ })
+ );
+ ReservationAccounting accounting =
+ LedgerBudgetComponents.reservationAccounting(stateStore);
+ ReservationId reservationId = reserve(
+ stateStore,
+ pricingSnapshot(),
+ usd("60.00")
+ );
+ accounting.markInFlight(reservationId);
+
+ ReservationReconciliation applied = accounting.commit(
+ command(reservationId)
+ );
+ ReservationReconciliation duplicate = accounting.commit(
+ command(reservationId)
+ );
+
+ assertThat(applied.transition().status().isApplied()).isTrue();
+ assertThat(duplicate.transition().status()).isEqualTo(REUSED);
+ assertThat(deliveryCount).hasValue(1);
+ assertThat(stateStore.snapshot(KEY, LIMIT).committedCost())
+ .isEqualTo(usd("40.00"));
+ }
+
+ @Test
+ @DisplayName("한 listener의 실패가 다음 listener의 이벤트 수신을 막지 않는다")
+ void continuesDeliveryAfterListenerFailure() {
+ List receivedEvents = new ArrayList<>();
+ AtomicInteger failedDeliveryCount = new AtomicInteger();
+ AtomicInteger sequence = new AtomicInteger();
+ BudgetStateStore stateStore = LedgerBudgetComponents.inMemoryBudgetStateStore(
+ CLOCK,
+ () -> new ReservationId(
+ "reservation-" + sequence.incrementAndGet()
+ ),
+ (usage, plan) -> usd("40.00"),
+ List.of(
+ event -> {
+ failedDeliveryCount.incrementAndGet();
+ throw new IllegalStateException("listener failed");
+ },
+ receivedEvents::add
+ )
+ );
+ ReservationAccounting accounting =
+ LedgerBudgetComponents.reservationAccounting(stateStore);
+ ReservationId reservationId = reserve(
+ stateStore,
+ pricingSnapshot(),
+ usd("60.00")
+ );
+ accounting.markInFlight(reservationId);
+
+ ReservationReconciliation applied = accounting.commit(
+ command(reservationId)
+ );
+
+ assertThat(failedDeliveryCount).hasValue(1);
+ assertThat(receivedEvents).containsExactly(
+ new ReservationAccountingEvent(applied)
+ );
+ }
+
@Test
@DisplayName("공개 factory는 예약 store와 같은 객체의 정산 진입점을 반환한다")
void exposesAccountingForTheSameReservationStore() {
From 452a3141053c20d82e3e1d68a1ba5f1e25c4d9bb Mon Sep 17 00:00:00 2001
From: rigu1
Date: Thu, 20 Aug 2026 12:34:08 +0900
Subject: [PATCH 10/13] =?UTF-8?q?docs:=20=ED=9A=8C=EA=B3=84=20=EC=A0=95?=
=?UTF-8?q?=EC=82=B0=20=EA=B5=AC=ED=98=84=20=EC=83=81=ED=83=9C=20=EB=B0=98?=
=?UTF-8?q?=EC=98=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 209f97c..e510277 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,7 +4,7 @@
Token Pilot is evolving from a Spring AI usage-tracking starter into a framework-independent Java LLM control and accounting core with optional framework and observability adapters.
-Current truth: post-call usage normalization, cost calculation, ledger events, Micrometer publishing, Clock-based monthly budget windows, pure budget decisions, typed missing-pricing policies, pricing snapshots, framework-independent token count results, a UTF-8 byte heuristic estimator, a preflight cost-bound projection, versioned model metadata, conservative context admission, a plain-Java core consumer verification path, and the framework-independent in-memory atomic reservation/idempotency foundation are implemented. Candidate-aware request production and estimate/actual cost reconciliation are 30-day MVP targets, not current capabilities.
+Current truth: post-call usage normalization, cost calculation, ledger events, Micrometer publishing, Clock-based monthly budget windows, pure budget decisions, typed missing-pricing policies, pricing snapshots, framework-independent token count results, a UTF-8 byte heuristic estimator, a preflight cost-bound projection, versioned model metadata, conservative context admission, a plain-Java core consumer verification path, framework-independent in-memory atomic reservations, and estimate/actual reconciliation with best-effort accounting events are implemented. Candidate-aware request production and Spring AI lifecycle integration remain 30-day MVP targets, not current capabilities.
Distribution direction: publish a framework-independent core and an optional Spring AI convenience starter from the same repository and release train. The existing starter artifact is `token-pilot-starter`; `token-pilot-spring-ai-starter` is only a target name until a compatibility ADR and module change land.
@@ -74,7 +74,7 @@ Token Pilot의 제품 포지션은 framework-independent Java LLM control and ac
| `token-pilot-core` | Basic implementation complete | Domain records, pricing, calculator, registry, ledger manager, pricing snapshots, versioned model catalog, token count results, UTF-8 byte heuristic estimation, preflight cost-bound projection, conservative context admission, and public plain-Java consumer verification |
| `token-pilot-spring-ai` | Basic implementation complete | Spring AI 2.0.0 `UsageExtractor`, `LedgerAdvisor`, pricing snapshot resolution, response usage recording, reconciliation decisions, and legacy provider-boundary BLOCK enforcement |
| `token-pilot-micrometer` | Basic implementation complete | `MetricsOptions`, tag whitelist, and metric metadata exist; metric ownership must be narrowed |
-| `token-pilot-budget` | Basic atomic reservation implementation | Typed monthly keys, Clock/ZoneId windows, pure status/admission decisions, safe-upper-bound reservations, bucket-scoped atomicity, idempotency, and framework-independent snapshots implemented; candidate production, lifecycle reconciliation, and durable stores remain |
+| `token-pilot-budget` | Atomic reservation and reconciliation implemented | Typed monthly keys, Clock/ZoneId windows, safe-upper-bound reservations, commit/release/write-off lifecycle, pending reconciliation liability, estimate/actual token and cost deltas, duplicate callback protection, and framework-independent best-effort accounting events implemented; candidate production and durable stores remain |
| `token-pilot-notification` | Basic implementation complete | Event API and deduplication exist; not yet connected to the full advisor/budget lifecycle |
| `token-pilot-autoconfigure` | Basic implementation complete | Bean registration, property binding, pricing/budget/notification wiring, and `ChatClientBuilderCustomizer` implemented |
| `token-pilot-starter` | Basic implementation complete | Thin final user entrypoint that brings runtime modules together |
@@ -329,6 +329,8 @@ The active checklist is in `docs/30_DAY_MVP_REPORT.md`; detailed long-term works
- The legacy `DefaultLedgerManager.record(String, ...)` path preserves an explicit zero USD fail-open result for a missing plan; the pricing-snapshot path applies `MissingPricingPolicy` and records `UNPRICED` or rejects before provider invocation, so neither behavior is a priced zero-rate plan.
- Spring AI usage extraction converts map/JSON-compatible native usage objects into the normalized core model. Real-provider compatibility fixtures remain required because provider and Spring AI usage shapes can change independently.
- The legacy provider boundary blocks an already-exhausted budget decision before provider invocation. Its candidate-free `STATUS` input is a regression guard, not admission evidence; the flow remains check-then-add and is not connected to the new atomic reservation lifecycle until #39.
+- In-memory reservation reconciliation uses the reservation-time pricing snapshot, moves estimate liability atomically between active, pending, and committed totals, and skips cost calculation for exact duplicate callbacks. Spring AI callback integration remains #39.
+- Accounting listeners run synchronously after the bucket lock is released. Runtime listener failures do not roll back a committed transition, stop later listeners, or trigger redelivery on duplicate callbacks, but delivery remains best-effort at-most-once without a durable outbox; failure observation remains #40.
- Current Micrometer `ai.token.*` metrics may duplicate Spring AI Observability; preserve compatibility while deciding default suppression or replacement.
- The verified Spring AI 2.0.0 path is synchronous `ChatClient` usage recording with a fake provider. Streaming cancellation and reconciliation remain outside the current compatibility guarantee.
- The repository, README, JReleaser configuration, and every published module POM use the MIT License. `verifyPublicationMetadata` guards this release contract and ensures the sample app is not published.
@@ -400,6 +402,11 @@ Stage and deploy a Central release:
## Update History
+### 2026-08-20
+
+- Added the reservation accounting lifecycle for dispatch, commit, release, unresolved actual usage, late actual reconciliation, and write-off with bucket-scoped atomic liability movement and idempotent terminal outcomes.
+- Added estimate/actual token and cost deltas, over-limit results, bounded accounting reasons, exact callback fingerprinting, and framework-independent accounting events delivered best-effort at most once without rolling back successful transitions on runtime listener failures.
+
### 2026-08-15
- Added the framework-independent `BudgetStateStore` atomic safe-upper-bound reservation contract with immutable reservation IDs, idempotency fingerprints, bucket-scoped concurrency control, currency-safe outcomes, and effective-usage snapshots.
From e756eba2c5fa7f661366c7179135c8d1ddbb8da3 Mon Sep 17 00:00:00 2001
From: rigu1
Date: Thu, 20 Aug 2026 12:47:45 +0900
Subject: [PATCH 11/13] =?UTF-8?q?test(budget):=20actual=20=EC=B4=88?=
=?UTF-8?q?=EA=B3=BC=20=EB=B0=8F=20callback=20timeout=20=EC=83=81=ED=99=A9?=
=?UTF-8?q?=EC=97=90=EC=84=9C=EC=9D=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?=
=?UTF-8?q?=EB=B3=B4=EC=99=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ReservationReconciliationTest.java | 62 +++++++++++++++++++
1 file changed, 62 insertions(+)
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
index 4d77717..22b3759 100644
--- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
@@ -12,6 +12,7 @@
import io.tokenpilot.budget.ReservationAccountingReason;
import io.tokenpilot.budget.ReservationActualTokens;
import io.tokenpilot.budget.ReservationReconciliation;
+import io.tokenpilot.budget.ReservationStatus;
import io.tokenpilot.budget.ReservationTokenEstimate;
import io.tokenpilot.core.CostCalculator;
import io.tokenpilot.core.domain.Cost;
@@ -128,6 +129,67 @@ void reportsOverLimitWhenActualExceedsBudgetLimit() {
assertThat(reconciliation.overLimit()).isTrue();
}
+ @Test
+ @DisplayName("actual 초과로 limit을 넘으면 비용을 반영하고 다음 예약을 차단한다")
+ void blocksNextReservationAfterActualExceedsLimit() {
+ InMemoryBudgetStateStore store = store((usage, plan) -> usd("120.00"));
+ PricingSnapshot snapshot = pricingSnapshot();
+ ReservationId reservationId = reserve(store, snapshot, usd("60.00"));
+ store.markInFlight(reservationId);
+
+ ReservationReconciliation reconciliation = store.commit(
+ command(reservationId)
+ );
+ var blocked = store.checkAndReserve(
+ new BudgetReservationRequest(
+ KEY,
+ LIMIT,
+ usd("1.00"),
+ "request-2",
+ new IdempotencyKey("deduplication-2"),
+ snapshot,
+ TOKEN_ESTIMATE
+ )
+ );
+
+ assertThat(reconciliation.overLimit()).isTrue();
+ assertThat(store.snapshot(KEY, LIMIT).committedCost())
+ .isEqualTo(usd("120.00"));
+ assertThat(blocked.status()).isEqualTo(ReservationStatus.BLOCKED);
+ }
+
+ @Test
+ @DisplayName("callback timeout은 estimate를 pending에 유지해 다음 예약에 반영한다")
+ void keepsTimedOutInFlightLiabilityInAdmission() {
+ InMemoryBudgetStateStore store = store((usage, plan) -> usd("40.00"));
+ PricingSnapshot snapshot = pricingSnapshot();
+ ReservationId reservationId = reserve(store, snapshot, usd("60.00"));
+ store.markInFlight(reservationId);
+
+ var transition = store.markReconciliationRequired(
+ reservationId,
+ ReservationAccountingReason.CALLBACK_TIMED_OUT
+ );
+ var blocked = store.checkAndReserve(
+ new BudgetReservationRequest(
+ KEY,
+ LIMIT,
+ usd("50.00"),
+ "request-2",
+ new IdempotencyKey("deduplication-2"),
+ snapshot,
+ TOKEN_ESTIMATE
+ )
+ );
+
+ assertThat(transition.previousState()).isEqualTo(IN_FLIGHT);
+ assertThat(transition.resultingState())
+ .isEqualTo(RECONCILIATION_REQUIRED);
+ assertThat(store.snapshot(KEY, LIMIT).pendingReconciliationLiability())
+ .isEqualTo(usd("60.00"));
+ assertThat(blocked.status()).isEqualTo(ReservationStatus.BLOCKED);
+ }
+
@Test
@DisplayName("actual 정산이 limit과 같으면 초과로 표시하지 않는다")
void doesNotReportOverLimitAtExactBudgetLimit() {
From c6b31f5d2dec4fde52b8ee2340168c2c6aea8473 Mon Sep 17 00:00:00 2001
From: rigu1
Date: Thu, 20 Aug 2026 12:56:15 +0900
Subject: [PATCH 12/13] =?UTF-8?q?docs(budget):=20=EA=B3=B5=EA=B0=9C=20?=
=?UTF-8?q?=ED=9A=8C=EA=B3=84=20=EC=83=81=ED=83=9C=20=EC=A0=84=EC=9D=B4=20?=
=?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=AA=85=EC=8B=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../budget/ReservationAccounting.java | 66 +++++++++++++++++++
1 file changed, 66 insertions(+)
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
index 72b6918..962bb8e 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
@@ -2,6 +2,72 @@
/**
* 예약의 회계 상태와 금액을 변경하는 단일 진입점입니다.
+ *
+ *
+ * 예약 회계 명령의 허용 전이와 금액 이동
+ *
+ *
+ * | 명령 |
+ * 허용 상태 |
+ * 결과 상태 |
+ * 금액 이동 |
+ *
+ *
+ *
+ *
+ * | {@link #markInFlight(ReservationId)} |
+ * {@link ReservationState#RESERVED} |
+ * {@link ReservationState#IN_FLIGHT} |
+ * 없음 |
+ *
+ *
+ * | {@code release(CANCELLED_BEFORE_DISPATCH)} |
+ * {@link ReservationState#RESERVED} |
+ * {@link ReservationState#RELEASED} |
+ * {@code activeReservedCost -= estimate} |
+ *
+ *
+ * | {@code release(PROVIDER_CONFIRMED_UNBILLED)} |
+ * {@link ReservationState#IN_FLIGHT} |
+ * {@link ReservationState#RELEASED} |
+ * {@code activeReservedCost -= estimate} |
+ *
+ *
+ * | {@link #commit(ActualUsageCommand)} |
+ * {@link ReservationState#IN_FLIGHT} |
+ * {@link ReservationState#COMMITTED} |
+ * {@code activeReservedCost -= estimate; committedCost += actual} |
+ *
+ *
+ * | {@link #markReconciliationRequired(ReservationId, ReservationAccountingReason)} |
+ * {@link ReservationState#IN_FLIGHT} |
+ * {@link ReservationState#RECONCILIATION_REQUIRED} |
+ * {@code activeReservedCost -= estimate; pendingReconciliationLiability += estimate} |
+ *
+ *
+ * | {@link #reconcileLateActual(ActualUsageCommand)} |
+ * {@link ReservationState#RECONCILIATION_REQUIRED} |
+ * {@link ReservationState#COMMITTED} |
+ * {@code pendingReconciliationLiability -= estimate; committedCost += actual} |
+ *
+ *
+ * | {@link #writeOff(ReservationId, ReservationAccountingReason)} |
+ * {@link ReservationState#RECONCILIATION_REQUIRED} |
+ * {@link ReservationState#WRITTEN_OFF} |
+ * {@code pendingReconciliationLiability -= estimate} |
+ *
+ *
+ *
+ *
+ * 표의 전이가 새로 적용되면 {@link AccountingTransitionStatus#APPLIED}입니다.
+ * 동일한 종료 명령과 값 또는 정산 대기 명령의 재호출은 상태와 금액을 유지하고
+ * {@link AccountingTransitionStatus#REUSED}, 다른 actual 또는 상충하는 종료 명령은
+ * {@link AccountingTransitionStatus#CONFLICT}입니다. 아직 종료 명령이 적용되지 않았지만
+ * 현재 상태가 표의 허용 상태가 아니면 {@link AccountingTransitionStatus#NOT_ALLOWED}입니다.
+ *
+ * 존재하지 않는 예약과 명령에 허용되지 않은 reason은 상태를 변경하기 전에
+ * {@link IllegalArgumentException}으로 거부합니다. 모든 상태와 금액 변경은 같은 budget
+ * bucket의 임계 구역 안에서 함께 적용됩니다.
*/
public interface ReservationAccounting {
From 44c937462ff4cf6f6765888b8687c0468e844f7d Mon Sep 17 00:00:00 2001
From: HuitaePark
Date: Sat, 22 Aug 2026 16:26:12 +0900
Subject: [PATCH 13/13] =?UTF-8?q?fix(budget):=20actual=20=EC=A0=95?=
=?UTF-8?q?=EC=82=B0=EA=B3=BC=20=ED=98=B8=ED=99=98=20=EC=98=88=EC=95=BD=20?=
=?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B3=B4=EA=B0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 7 +-
.../tokenpilot/budget/ActualUsageCommand.java | 6 +
.../budget/ReservationAccounting.java | 21 ++++
.../internal/InMemoryBudgetStateStore.java | 18 ++-
.../internal/ReservationAccountingState.java | 3 +
.../internal/ReservationAccountingTest.java | 22 ++++
.../ReservationReconciliationTest.java | 105 ++++++++++++++++++
7 files changed, 179 insertions(+), 3 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index e510277..72e3a05 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -329,7 +329,7 @@ The active checklist is in `docs/30_DAY_MVP_REPORT.md`; detailed long-term works
- The legacy `DefaultLedgerManager.record(String, ...)` path preserves an explicit zero USD fail-open result for a missing plan; the pricing-snapshot path applies `MissingPricingPolicy` and records `UNPRICED` or rejects before provider invocation, so neither behavior is a priced zero-rate plan.
- Spring AI usage extraction converts map/JSON-compatible native usage objects into the normalized core model. Real-provider compatibility fixtures remain required because provider and Spring AI usage shapes can change independently.
- The legacy provider boundary blocks an already-exhausted budget decision before provider invocation. Its candidate-free `STATUS` input is a regression guard, not admission evidence; the flow remains check-then-add and is not connected to the new atomic reservation lifecycle until #39.
-- In-memory reservation reconciliation uses the reservation-time pricing snapshot, moves estimate liability atomically between active, pending, and committed totals, and skips cost calculation for exact duplicate callbacks. Spring AI callback integration remains #39.
+- In-memory reservation reconciliation uses the reservation-time pricing snapshot, accepts only provider-reported or provider-derived actual usage, moves estimate liability atomically between active, pending, and committed totals, and skips cost calculation for exact duplicate callbacks. Legacy reservations without pricing/token metadata have an explicit cost-only settlement path; new reservations should use the usage-based API. Spring AI callback integration remains #39.
- Accounting listeners run synchronously after the bucket lock is released. Runtime listener failures do not roll back a committed transition, stop later listeners, or trigger redelivery on duplicate callbacks, but delivery remains best-effort at-most-once without a durable outbox; failure observation remains #40.
- Current Micrometer `ai.token.*` metrics may duplicate Spring AI Observability; preserve compatibility while deciding default suppression or replacement.
- The verified Spring AI 2.0.0 path is synchronous `ChatClient` usage recording with a fake provider. Streaming cancellation and reconciliation remain outside the current compatibility guarantee.
@@ -402,6 +402,11 @@ Stage and deploy a Central release:
## Update History
+### 2026-08-22
+
+- Restricted usage-based reservation reconciliation to provider-reported or provider-derived usage so local and heuristic estimates cannot be committed as actual spend.
+- Added public cost-only commit and late-actual compatibility paths for legacy reservations without pricing snapshots or token estimates, and made direct commit after write-off return `CONFLICT` consistently with late actual.
+
### 2026-08-20
- Added the reservation accounting lifecycle for dispatch, commit, release, unresolved actual usage, late actual reconciliation, and write-off with bucket-scoped atomic liability movement and idempotent terminal outcomes.
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ActualUsageCommand.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ActualUsageCommand.java
index 1b4ca84..c7bb159 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ActualUsageCommand.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ActualUsageCommand.java
@@ -26,6 +26,12 @@ public record ActualUsageCommand(
"usage must be available for actual reconciliation"
);
}
+ if (usage.source() != UsageSource.PROVIDER_REPORTED
+ && usage.source() != UsageSource.PROVIDER_DERIVED) {
+ throw new IllegalArgumentException(
+ "usage source must be provider-reported or provider-derived"
+ );
+ }
responseModelId = requireText(responseModelId, "responseModelId");
}
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
index 962bb8e..e5f8494 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
@@ -1,5 +1,7 @@
package io.tokenpilot.budget;
+import io.tokenpilot.core.domain.Cost;
+
/**
* 예약의 회계 상태와 금액을 변경하는 단일 진입점입니다.
*
@@ -95,6 +97,15 @@ ReservationTransition release(
ReservationAccountingReason reason
);
+ /**
+ * pricing snapshot과 token estimate가 없던 호환 예약을 caller가 계산한 actual 비용으로 확정합니다.
+ *
+ * 이 호환 경로는 token/model correlation을 포함한
+ * {@link #commit(ActualUsageCommand)} 결과와 회계 이벤트를 만들 수 없으므로,
+ * 신규 예약에는 usage 기반 API를 사용해야 합니다.
+ */
+ ReservationTransition commitCost(ReservationId reservationId, Cost actualCost);
+
/** provider actual usage를 예약 시점 가격으로 계산하여 확정합니다. */
ReservationReconciliation commit(ActualUsageCommand command);
@@ -116,6 +127,16 @@ ReservationTransition markReconciliationRequired(
/** 늦게 도착한 provider actual usage를 예약 시점 가격으로 계산하여 확정합니다. */
ReservationReconciliation reconcileLateActual(ActualUsageCommand command);
+ /**
+ * pricing snapshot과 token estimate가 없던 pending 호환 예약을 caller가 계산한 actual 비용으로 확정합니다.
+ *
+ * 신규 예약에는 {@link #reconcileLateActual(ActualUsageCommand)}를 사용해야 합니다.
+ */
+ ReservationTransition reconcileLateActualCost(
+ ReservationId reservationId,
+ Cost actualCost
+ );
+
/** 후속 정산할 수 없는 pending 예약을 명시적으로 상각합니다. */
default ReservationTransition writeOff(ReservationId reservationId) {
return writeOff(
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
index dfb3d71..7d5e1a1 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
@@ -234,7 +234,8 @@ private ReservationTransition releaseConfirmedUnbilled(
}
}
- ReservationTransition commitCost(ReservationId reservationId, Cost actualCost) {
+ @Override
+ public ReservationTransition commitCost(ReservationId reservationId, Cost actualCost) {
return applyCost(
reservationId,
actualCost,
@@ -279,7 +280,8 @@ public ReservationTransition markReconciliationRequired(
}
}
- ReservationTransition reconcileLateActualCost(
+ @Override
+ public ReservationTransition reconcileLateActualCost(
ReservationId reservationId,
Cost actualCost
) {
@@ -306,6 +308,9 @@ private AccountingTransitionOutcome applyCost(
bucket,
reservationId
);
+ if (fingerprint.isEmpty()) {
+ requireCostOnlyReservation(accountingState.reservation());
+ }
return applyCostInBucket(
bucket,
accountingState,
@@ -926,6 +931,15 @@ private static ReservationAccountingState accountingState(
);
}
+ private static void requireCostOnlyReservation(BudgetReservation reservation) {
+ if (reservation.pricingSnapshot().isPresent()
+ && reservation.tokenEstimate().isPresent()) {
+ throw new IllegalStateException(
+ "usage-based reconciliation is required for reservations with pricing and token metadata"
+ );
+ }
+ }
+
private static Cost subtract(Cost total, Cost amount) {
if (total.compareTo(amount) < 0) {
throw new IllegalStateException("reserved cost must not become negative");
diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java
index 80fbaee..bb06898 100644
--- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java
@@ -69,6 +69,9 @@ ReservationTransition evaluateCommit(
CURRENCY_MISMATCH
);
}
+ if (reservation.state() == WRITTEN_OFF) {
+ return ReservationTransition.unchanged(WRITTEN_OFF, CONFLICT);
+ }
if (appliedRelease.isPresent()) {
return ReservationTransition.unchanged(reservation.state(), CONFLICT);
}
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java
index 3988940..6cf4ae2 100644
--- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java
@@ -704,6 +704,28 @@ void rejectsLateActualAfterWriteOff() {
assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
}
+ @Test
+ @DisplayName("write-off 이후 direct commit도 기존 상각을 보존하고 충돌을 반환한다")
+ void rejectsDirectCommitAfterWriteOff() {
+ InMemoryBudgetStateStore store = store();
+ ReservationId reservationId = reserveInFlight(store, store, usd("60.00"));
+ store.markReconciliationRequired(reservationId);
+ store.writeOff(reservationId);
+
+ ReservationTransition transition = store.commitCost(
+ reservationId,
+ usd("40.00")
+ );
+
+ assertThat(transition).isEqualTo(
+ ReservationTransition.unchanged(WRITTEN_OFF, CONFLICT)
+ );
+ BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT);
+ assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.committedCost()).isEqualTo(usd("0.00"));
+ assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00"));
+ }
+
@Test
@DisplayName("late actual 정산 이후 write-off를 요청하면 기존 정산을 보존하고 충돌을 반환한다")
void rejectsWriteOffAfterLateActual() {
diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
index 22b3759..992aa08 100644
--- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
@@ -20,8 +20,12 @@
import io.tokenpilot.core.domain.PricingSnapshot;
import io.tokenpilot.core.domain.TokenType;
import io.tokenpilot.core.domain.TokenUsage;
+import io.tokenpilot.core.domain.TokenUsageDetails;
+import io.tokenpilot.core.domain.UsageSource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
import java.math.BigDecimal;
import java.time.Clock;
@@ -413,6 +417,107 @@ void rejectsUnavailableActualUsage() {
.hasMessage("usage must be available for actual reconciliation");
}
+ @ParameterizedTest
+ @EnumSource(
+ value = UsageSource.class,
+ names = {"PROVIDER_REPORTED", "PROVIDER_DERIVED"},
+ mode = EnumSource.Mode.EXCLUDE
+ )
+ @DisplayName("provider actual이 아닌 usage는 정산 명령으로 만들지 않는다")
+ void rejectsNonProviderUsageSources(UsageSource source) {
+ TokenUsage usage = new TokenUsage(
+ 100,
+ 50,
+ TokenUsageDetails.unreported(),
+ source,
+ Map.of()
+ );
+
+ assertThatThrownBy(
+ () -> new ActualUsageCommand(
+ "request-1",
+ "attempt-1",
+ new ReservationId("reservation-1"),
+ usage,
+ "gpt-4o-mini-response"
+ )
+ ).isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ @DisplayName("snapshot 없는 호환 예약은 공개 cost-only commit으로 정산한다")
+ void settlesLegacyReservationThroughCostOnlyCommit() {
+ InMemoryBudgetStateStore store = store((usage, plan) -> usd("40.00"));
+ ReservationAccounting accounting =
+ LedgerBudgetComponents.reservationAccounting(store);
+ ReservationId reservationId = store.checkAndReserve(
+ KEY,
+ LIMIT,
+ usd("60.00"),
+ "legacy-request"
+ ).reservationId();
+ accounting.markInFlight(reservationId);
+
+ var transition = accounting.commitCost(reservationId, usd("40.00"));
+
+ assertThat(transition.previousState()).isEqualTo(IN_FLIGHT);
+ assertThat(transition.resultingState()).isEqualTo(COMMITTED);
+ assertThat(store.snapshot(KEY, LIMIT).activeReservedCost())
+ .isEqualTo(usd("0.00"));
+ assertThat(store.snapshot(KEY, LIMIT).committedCost())
+ .isEqualTo(usd("40.00"));
+ }
+
+ @Test
+ @DisplayName("snapshot 없는 pending 호환 예약은 공개 cost-only late actual로 정산한다")
+ void settlesLegacyPendingReservationThroughCostOnlyLateActual() {
+ InMemoryBudgetStateStore store = store((usage, plan) -> usd("40.00"));
+ ReservationAccounting accounting =
+ LedgerBudgetComponents.reservationAccounting(store);
+ ReservationId reservationId = store.checkAndReserve(
+ KEY,
+ LIMIT,
+ usd("60.00"),
+ "legacy-request"
+ ).reservationId();
+ accounting.markInFlight(reservationId);
+ accounting.markReconciliationRequired(reservationId);
+
+ var transition = accounting.reconcileLateActualCost(
+ reservationId,
+ usd("40.00")
+ );
+
+ assertThat(transition.previousState()).isEqualTo(RECONCILIATION_REQUIRED);
+ assertThat(transition.resultingState()).isEqualTo(COMMITTED);
+ assertThat(store.snapshot(KEY, LIMIT).pendingReconciliationLiability())
+ .isEqualTo(usd("0.00"));
+ assertThat(store.snapshot(KEY, LIMIT).committedCost())
+ .isEqualTo(usd("40.00"));
+ }
+
+ @Test
+ @DisplayName("완전한 usage metadata 예약은 cost-only 경로로 우회 정산하지 않는다")
+ void doesNotBypassUsageReconciliationWithCostOnlyCommit() {
+ InMemoryBudgetStateStore store = store((usage, plan) -> usd("40.00"));
+ ReservationAccounting accounting =
+ LedgerBudgetComponents.reservationAccounting(store);
+ ReservationId reservationId = reserve(
+ store,
+ pricingSnapshot(),
+ usd("60.00")
+ );
+ accounting.markInFlight(reservationId);
+ var before = store.snapshot(KEY, LIMIT);
+
+ assertThatThrownBy(() -> accounting.commitCost(reservationId, usd("40.00")))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage(
+ "usage-based reconciliation is required for reservations with pricing and token metadata"
+ );
+ assertThat(store.snapshot(KEY, LIMIT)).isEqualTo(before);
+ }
+
@Test
@DisplayName("동일 commit callback은 actual 비용을 다시 계산하지 않는다")
void doesNotRecalculateCostForDuplicateCommitCallback() {