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() {