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.
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..00df53c
--- /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/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..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
@@ -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,13 @@ public record BudgetReservation(
BudgetKey key,
Cost limit,
Cost amount,
+ String requestId,
IdempotencyKey idempotencyKey,
String modelId,
String pricingPolicyId,
String catalogVersion,
+ Optional pricingSnapshot,
+ Optional tokenEstimate,
ReservationState state,
Instant createdAt
) {
@@ -26,6 +31,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 +44,67 @@ public record BudgetReservation(
modelId = optionalText(modelId, "modelId");
pricingPolicyId = optionalText(pricingPolicyId, "pricingPolicyId");
catalogVersion = optionalText(catalogVersion, "catalogVersion");
+ pricingSnapshot = Objects.requireNonNull(
+ 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");
+ 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(),
+ Optional.empty(),
+ state,
+ createdAt
+ );
}
public static BudgetReservation reserved(
@@ -51,10 +118,13 @@ public static BudgetReservation reserved(
request.key(),
request.limit(),
request.safeUpperBoundCost(),
+ request.requestId(),
request.idempotencyKey(),
request.modelId(),
request.pricingPolicyId(),
request.catalogVersion(),
+ request.pricingSnapshot(),
+ request.tokenEstimate(),
ReservationState.RESERVED,
createdAt
);
@@ -64,10 +134,18 @@ 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())
+ && tokenEstimate.equals(request.tokenEstimate());
+ }
+
+ /** 이 예약이 지정한 provider 요청에 속하는지 확인합니다. */
+ public boolean belongsTo(String candidateRequestId) {
+ return requestId.equals(candidateRequestId);
}
private static String optionalText(String value, String name) {
@@ -76,4 +154,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..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
@@ -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,20 @@ public record BudgetReservationRequest(
BudgetKey key,
Cost limit,
Cost safeUpperBoundCost,
+ String requestId,
IdempotencyKey idempotencyKey,
String modelId,
String pricingPolicyId,
- String catalogVersion
+ String catalogVersion,
+ Optional pricingSnapshot,
+ Optional tokenEstimate
) {
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 +38,96 @@ public record BudgetReservationRequest(
modelId = optionalText(modelId, "modelId");
pricingPolicyId = optionalText(pricingPolicyId, "pricingPolicyId");
catalogVersion = optionalText(catalogVersion, "catalogVersion");
+ pricingSnapshot = Objects.requireNonNull(
+ 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");
+ 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(),
+ 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,
+ Optional.empty()
+ );
+ }
+
+ /**
+ * @deprecated request ID와 idempotency key를 같은 값으로 사용하는 호환 생성자입니다.
+ */
+ @Deprecated(since = "0.1.0", forRemoval = false)
public BudgetReservationRequest(
BudgetKey key,
Cost limit,
@@ -44,13 +138,20 @@ public BudgetReservationRequest(
key,
limit,
safeUpperBoundCost,
+ idempotencyKey,
new IdempotencyKey(idempotencyKey),
null,
null,
- null
+ null,
+ Optional.empty(),
+ Optional.empty()
);
}
+ /**
+ * @deprecated request ID와 idempotency key를 같은 값으로 사용하는 호환 생성자입니다.
+ */
+ @Deprecated(since = "0.1.0", forRemoval = false)
public BudgetReservationRequest(
BudgetKey key,
Cost limit,
@@ -64,10 +165,61 @@ public BudgetReservationRequest(
key,
limit,
safeUpperBoundCost,
+ idempotencyKey,
new IdempotencyKey(idempotencyKey),
modelId,
pricingPolicyId,
- catalogVersion
+ 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)
);
}
@@ -77,4 +229,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
new file mode 100644
index 0000000..962bb8e
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java
@@ -0,0 +1,131 @@
+package io.tokenpilot.budget;
+
+/**
+ * 예약의 회계 상태와 금액을 변경하는 단일 진입점입니다.
+ *
+ *
+ * 예약 회계 명령의 허용 전이와 금액 이동
+ *
+ *
+ * | 명령 |
+ * 허용 상태 |
+ * 결과 상태 |
+ * 금액 이동 |
+ *
+ *
+ *
+ *
+ * | {@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 {
+
+ /** 예약을 사용한 provider 호출 시작을 기록합니다. */
+ ReservationTransition markInFlight(ReservationId reservationId);
+
+ /** provider 호출 전에 사용하지 않은 예약을 해제합니다. */
+ default ReservationTransition releaseBeforeDispatch(ReservationId reservationId) {
+ return release(
+ reservationId,
+ ReservationAccountingReason.CANCELLED_BEFORE_DISPATCH
+ );
+ }
+
+ /** provider가 미과금을 확인한 진행 중 예약을 해제합니다. */
+ 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을 확보하지 못한 예약을 정산 대기로 전환합니다. */
+ 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 예약을 명시적으로 상각합니다. */
+ 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/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/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
new file mode 100644
index 0000000..be34bea
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationReconciliation.java
@@ -0,0 +1,107 @@
+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,
+ ReservationTokenEstimate tokenEstimate,
+ ReservationActualTokens actualTokens,
+ Cost estimate,
+ Cost actual,
+ boolean overLimit,
+ ReservationTransition transition,
+ ReservationAccountingReason reason
+) {
+
+ 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(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"
+ );
+ }
+ 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());
+ }
+
+ 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();
+ }
+
+ 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/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/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..812b575
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java
@@ -0,0 +1,94 @@
+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
+ );
+ }
+
+ /** 공급자가 미과금을 확인한 진행 중 예약을 해제합니다. */
+ public static ReservationTransition releaseConfirmedUnbilled(
+ ReservationState currentState
+ ) {
+ return transitionFrom(
+ currentState,
+ ReservationState.IN_FLIGHT,
+ ReservationState.RELEASED
+ );
+ }
+
+ /** 전달받은 actual을 확정하기 위한 상태 전이를 판단합니다. */
+ public static ReservationTransition commit(ReservationState currentState) {
+ return transitionFrom(
+ currentState,
+ ReservationState.IN_FLIGHT,
+ ReservationState.COMMITTED
+ );
+ }
+
+ /** actual을 전달받지 못한 예약을 정산 대기로 전환할 수 있는지 판단합니다. */
+ public static ReservationTransition markReconciliationRequired(ReservationState currentState) {
+ return transitionFrom(
+ currentState,
+ ReservationState.IN_FLIGHT,
+ ReservationState.RECONCILIATION_REQUIRED
+ );
+ }
+
+ /** 정산 대기 중 전달받은 late actual을 확정할 수 있는지 판단합니다. */
+ public static ReservationTransition reconcileLateActual(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/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/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/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
new file mode 100644
index 0000000..5e4c937
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedCommit.java
@@ -0,0 +1,94 @@
+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 java.util.Optional;
+
+import static io.tokenpilot.budget.AccountingTransitionStatus.CONFLICT;
+import static io.tokenpilot.budget.AccountingTransitionStatus.REUSED;
+
+/**
+ * 이미 적용된 commit의 종류, actual과 재호출 판단을 보관합니다.
+ */
+record AppliedCommit(
+ CommitType type,
+ Cost actualCost,
+ boolean overLimit,
+ Optional fingerprint
+) {
+
+ AppliedCommit {
+ 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 costOnly(CommitType type, Cost actualCost) {
+ return new AppliedCommit(
+ type,
+ actualCost,
+ false,
+ Optional.empty()
+ );
+ }
+
+ 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(
+ CommitType requestedType,
+ ReservationState state,
+ Cost requestedActualCost,
+ Optional requestedFingerprint
+ ) {
+ 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 = type == requestedType
+ && actualCost.equals(requestedActualCost)
+ && matchesFingerprintWhenProvided(requestedFingerprint)
+ ? REUSED
+ : CONFLICT;
+ return ReservationTransition.unchanged(state, status);
+ }
+
+ 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
new file mode 100644
index 0000000..fdc6c8d
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedRelease.java
@@ -0,0 +1,39 @@
+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(ReleaseType type) {
+
+ AppliedRelease {
+ Objects.requireNonNull(type, "type must not be null");
+ }
+
+ static AppliedRelease beforeDispatch() {
+ return new AppliedRelease(ReleaseType.BEFORE_DISPATCH);
+ }
+
+ static AppliedRelease confirmedUnbilled() {
+ return new AppliedRelease(ReleaseType.CONFIRMED_UNBILLED);
+ }
+
+ ReservationTransition evaluate(
+ ReleaseType requestedType,
+ ReservationState state
+ ) {
+ Objects.requireNonNull(requestedType, "requestedType must not be null");
+ Objects.requireNonNull(state, "state must not be null");
+
+ AccountingTransitionStatus status = type == requestedType ? REUSED : CONFLICT;
+ return ReservationTransition.unchanged(state, status);
+ }
+}
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 97bb3de..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
@@ -1,5 +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;
@@ -7,13 +9,29 @@
import io.tokenpilot.budget.BudgetSnapshot;
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;
+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;
+import io.tokenpilot.core.internal.LedgerComponents;
import java.time.Clock;
import java.util.LinkedHashMap;
+import java.util.List;
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;
@@ -25,27 +43,66 @@
* 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;
+ private final CostCalculator costCalculator;
+ private final List accountingListeners;
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, 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(
reservationIdGenerator,
"reservationIdGenerator must not be null"
);
+ this.costCalculator = Objects.requireNonNull(
+ costCalculator,
+ "costCalculator must not be null"
+ );
+ this.accountingListeners = List.copyOf(
+ Objects.requireNonNull(
+ accountingListeners,
+ "accountingListeners must not be null"
+ )
+ );
}
@Override
@@ -79,88 +136,251 @@ 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 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(
+ 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;
+ }
+ }
+
+ private ReservationTransition releaseConfirmedUnbilled(
+ ReservationId reservationId,
+ ReservationAccountingReason reason
+ ) {
+ 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"
- );
- }
-
- bucket.activeReservedCost = bucket.activeReservedCost.add(
- request.safeUpperBoundCost()
+ BudgetReservation updated = withState(
+ reservation,
+ transition.resultingState()
+ );
+ releaseActiveReservation(
+ bucket,
+ accountingState,
+ accountingState.confirmedUnbilledReleased(updated)
+ );
+ return transition;
+ }
+ }
+
+ ReservationTransition commitCost(ReservationId reservationId, Cost actualCost) {
+ return applyCost(
+ reservationId,
+ actualCost,
+ CommitType.DIRECT,
+ Optional.empty()
+ ).transition();
+ }
+
+ @Override
+ public ReservationReconciliation commit(ActualUsageCommand command) {
+ return reconcileUsage(
+ command,
+ CommitType.DIRECT,
+ ReservationAccountingReason.ACTUAL_USAGE_REPORTED
+ );
+ }
+
+ @Override
+ public ReservationTransition markReconciliationRequired(
+ ReservationId reservationId,
+ ReservationAccountingReason reason
+ ) {
+ requireReconciliationRequiredReason(reason);
+ Bucket bucket = bucketFor(reservationId);
+ synchronized (bucket) {
+ ReservationAccountingState accountingState = accountingState(
+ bucket,
+ reservationId
+ );
+ ReservationTransition transition =
+ accountingState.evaluateReconciliationRequired();
+ if (!transition.status().isApplied()) {
+ return transition;
+ }
+
+ moveActiveReservationToPending(
+ bucket,
+ accountingState,
+ transition.resultingState()
+ );
+ return transition;
+ }
+ }
+
+ 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
+ );
+ return applyCostInBucket(
+ bucket,
+ accountingState,
+ actualCost,
+ type,
+ fingerprint
+ );
+ }
+ }
+
+ 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
);
- bucket.reservationsById.put(reservationId, reservation);
- result.set(BudgetReservationResult.created(
- reservation,
- bucket.snapshot(request.key())
- ));
- return reservation;
+ return new AccountingTransitionOutcome(transition, overLimit);
+ }
+
+ @Override
+ public ReservationReconciliation reconcileLateActual(ActualUsageCommand command) {
+ return reconcileUsage(
+ command,
+ CommitType.LATE_ACTUAL,
+ ReservationAccountingReason.LATE_ACTUAL_USAGE_REPORTED
+ );
+ }
+
+ @Override
+ public ReservationTransition writeOff(
+ ReservationId reservationId,
+ ReservationAccountingReason reason
+ ) {
+ requireWriteOffReason(reason);
+ Bucket bucket = bucketFor(reservationId);
+ synchronized (bucket) {
+ ReservationAccountingState accountingState = accountingState(
+ bucket,
+ reservationId
+ );
+ ReservationTransition transition = accountingState.evaluateWriteOff();
+ if (!transition.status().isApplied()) {
+ return transition;
}
- });
- return Objects.requireNonNull(result.get(), "reservation result must be set");
+ writeOffPendingReservation(
+ bucket,
+ accountingState,
+ transition.resultingState()
+ );
+ return transition;
+ }
}
@Override
@@ -190,12 +410,556 @@ private void validateCurrency(Cost limit, Cost amount) {
}
}
+ private ReservationReconciliation reconcileUsage(
+ ActualUsageCommand command,
+ CommitType type,
+ ReservationAccountingReason reason
+ ) {
+ Objects.requireNonNull(command, "command must not be null");
+ 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) {
+ 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
+ );
+ 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(
+ 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"
+ );
+ }
+
+ PricingSnapshot snapshot = reservation.pricingSnapshot().orElseThrow(
+ () -> new IllegalStateException(
+ "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);
+ AccountingTransitionOutcome outcome = applyCostInBucket(
+ bucket,
+ accountingState,
+ actualCost,
+ type,
+ Optional.of(fingerprint)
+ );
+ return reconciliation(
+ command,
+ reservation,
+ snapshot,
+ tokenEstimate,
+ actualTokens,
+ actualCost,
+ outcome,
+ reason
+ );
+ }
+
+ 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,
+ ReservationTokenEstimate tokenEstimate,
+ ReservationActualTokens actualTokens,
+ Cost actualCost,
+ AccountingTransitionOutcome outcome,
+ ReservationAccountingReason reason
+ ) {
+ return new ReservationReconciliation(
+ command.requestId(),
+ command.attemptId(),
+ reservation.id(),
+ reservation.key(),
+ command.responseModelId(),
+ snapshot,
+ tokenEstimate,
+ actualTokens,
+ reservation.amount(),
+ actualCost,
+ outcome.overLimit(),
+ outcome.transition(),
+ reason
+ );
+ }
+
+ private static AccountingTransitionOutcome outcome(
+ Bucket bucket,
+ ReservationTransition transition
+ ) {
+ boolean overLimit = bucket.effectiveUsage().compareTo(bucket.limit) > 0;
+ return new AccountingTransitionOutcome(transition, overLimit);
+ }
+
+ 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 boolean commitActiveReservation(
+ Bucket bucket,
+ ReservationAccountingState accountingState,
+ Cost actualCost,
+ ReservationState resultingState,
+ Optional fingerprint
+ ) {
+ BudgetReservation reservation = accountingState.reservation();
+ Cost remainingReserved = subtract(
+ bucket.activeReservedCost,
+ reservation.amount()
+ );
+ 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,
+ updatedAccountingState
+ );
+ return overLimit;
+ }
+
+ 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 boolean commitPendingReservation(
+ Bucket bucket,
+ ReservationAccountingState accountingState,
+ Cost actualCost,
+ ReservationState resultingState,
+ Optional fingerprint
+ ) {
+ BudgetReservation reservation = accountingState.reservation();
+ Cost remainingPending = subtract(
+ bucket.pendingReconciliationLiability,
+ reservation.amount()
+ );
+ 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,
+ updatedAccountingState
+ );
+ return overLimit;
+ }
+
+ 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 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,
+ 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.requestId(),
+ reservation.idempotencyKey(),
+ reservation.modelId(),
+ reservation.pricingPolicyId(),
+ reservation.catalogVersion(),
+ reservation.pricingSnapshot(),
+ reservation.tokenEstimate(),
+ 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 +990,30 @@ 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());
+ }
+ }
+
+ 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/LedgerBudgetComponents.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java
index 438df18..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
@@ -3,9 +3,14 @@
import io.tokenpilot.budget.BudgetEvaluator;
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;
/**
@@ -27,6 +32,47 @@ 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
+ );
+ }
+
+ public static BudgetStateStore inMemoryBudgetStateStore(
+ Clock clock,
+ Supplier reservationIdGenerator,
+ CostCalculator costCalculator,
+ List accountingListeners
+ ) {
+ return new InMemoryBudgetStateStore(
+ clock,
+ reservationIdGenerator,
+ costCalculator,
+ accountingListeners
+ );
+ }
+
+ /**
+ * 예약을 생성한 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/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
new file mode 100644
index 0000000..80fbaee
--- /dev/null
+++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java
@@ -0,0 +1,271 @@
+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.RECONCILIATION_REQUIRED;
+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,
+ 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(),
+ CURRENCY_MISMATCH
+ );
+ }
+ if (appliedRelease.isPresent()) {
+ return ReservationTransition.unchanged(reservation.state(), CONFLICT);
+ }
+ return appliedCommit
+ .map(commit -> commit.evaluate(
+ CommitType.DIRECT,
+ reservation.state(),
+ actualCost,
+ fingerprint
+ ))
+ .orElseGet(() -> ReservationStateMachine.commit(reservation.state()));
+ }
+
+ 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(),
+ 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(
+ CommitType.LATE_ACTUAL,
+ reservation.state(),
+ actualCost,
+ fingerprint
+ ))
+ .orElseGet(
+ () -> ReservationStateMachine.reconcileLateActual(
+ reservation.state()
+ )
+ );
+ }
+
+ ReservationTransition evaluateReleaseBeforeDispatch() {
+ if (appliedCommit.isPresent()) {
+ return ReservationTransition.unchanged(reservation.state(), CONFLICT);
+ }
+ return appliedRelease
+ .map(release -> release.evaluate(
+ ReleaseType.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(
+ ReleaseType.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());
+ }
+
+ 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,
+ 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.costOnly(CommitType.DIRECT, actualCost)),
+ Optional.empty()
+ );
+ }
+
+ ReservationAccountingState lateActualCommitted(
+ BudgetReservation updatedReservation,
+ Cost actualCost
+ ) {
+ return new ReservationAccountingState(
+ updatedReservation,
+ 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()
+ );
+ }
+
+ private boolean hasDifferentCurrency(Cost actualCost) {
+ return !reservation.amount().currency().equals(actualCost.currency());
+ }
+}
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/ReservationStateMachineTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java
new file mode 100644
index 0000000..f642e25
--- /dev/null
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java
@@ -0,0 +1,130 @@
+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("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() {
+ var transition = ReservationStateMachine.commit(IN_FLIGHT);
+
+ assertThat(transition.previousState()).isEqualTo(IN_FLIGHT);
+ assertThat(transition.resultingState()).isEqualTo(COMMITTED);
+ assertThat(transition.status()).isEqualTo(APPLIED);
+ }
+
+ @Test
+ @DisplayName("actual을 확보하지 못해 정산 대기를 요청하면 정산 대기 상태가 된다")
+ void marksReconciliationRequiredWithoutActual() {
+ var transition = ReservationStateMachine.markReconciliationRequired(IN_FLIGHT);
+
+ assertThat(transition.previousState()).isEqualTo(IN_FLIGHT);
+ assertThat(transition.resultingState()).isEqualTo(RECONCILIATION_REQUIRED);
+ assertThat(transition.status()).isEqualTo(APPLIED);
+ }
+
+ @Test
+ @DisplayName("late actual을 전달해 reconcile하면 비용이 확정된다")
+ void reconcilesReservationWithLateActual() {
+ var transition = ReservationStateMachine.reconcileLateActual(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.commit(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/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();
+ }
+}
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);
+ }
+}
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
new file mode 100644
index 0000000..3988940
--- /dev/null
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java
@@ -0,0 +1,759 @@
+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.ReservationAccountingReason;
+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.commitCost(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.commitCost(reusedReservation, usd("40.00"));
+ BudgetSnapshot beforeReused = reusedStore.snapshot(KEY, LIMIT);
+
+ ReservationTransition reused = reusedStore.commitCost(
+ 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.commitCost(conflictReservation, usd("40.00"));
+ BudgetSnapshot beforeConflict = conflictStore.snapshot(KEY, LIMIT);
+
+ ReservationTransition conflict = conflictStore.commitCost(
+ 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.commitCost(
+ 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.commitCost(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.commitCost(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.commitCost(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.commitCost(
+ 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.commitCost(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.commitCost(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.commitCost(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.commitCost(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.commitCost(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.commitCost(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.commitCost(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("정산 대기 명령을 반복해도 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() {
+ 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.commitCost(
+ 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.reconcileLateActualCost(
+ 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.reconcileLateActualCost(
+ 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.reconcileLateActualCost(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.reconcileLateActualCost(
+ 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.reconcileLateActualCost(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.reconcileLateActualCost(
+ 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.reconcileLateActualCost(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.commitCost(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.commitCost(reservationId, usd("40.00"));
+
+ ReservationTransition transition = store.reconcileLateActualCost(
+ 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 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() {
+ 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.reconcileLateActualCost(
+ 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.reconcileLateActualCost(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);
+ }
+}
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..22b3759
--- /dev/null
+++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java
@@ -0,0 +1,770 @@
+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.ReservationAccountingEvent;
+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;
+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.ArrayList;
+import java.util.List;
+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 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;
+
+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
+ );
+ private static final ReservationTokenEstimate TOKEN_ESTIMATE =
+ new ReservationTokenEstimate(90, 100, 50);
+
+ @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);
+ assertThat(reconciliation.reason())
+ .isEqualTo(ReservationAccountingReason.ACTUAL_USAGE_REPORTED);
+ }
+
+ @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("actual 정산으로 limit을 넘으면 결과에 초과 상태를 남긴다")
+ void reportsOverLimitWhenActualExceedsBudgetLimit() {
+ ReservationReconciliation reconciliation = reconcile("60.00", "120.00");
+
+ 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() {
+ ReservationReconciliation reconciliation = reconcile("60.00", "100.00");
+
+ assertThat(reconciliation.overLimit()).isFalse();
+ }
+
+ @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.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
+ @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");
+ }
+
+ @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(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"));
+ }
+
+ @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("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() {
+ 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("동일 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("새롭게 적용된 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("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() {
+ 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(
+ BudgetStateStore store,
+ PricingSnapshot snapshot,
+ Cost estimate
+ ) {
+ return store.checkAndReserve(
+ new BudgetReservationRequest(
+ KEY,
+ LIMIT,
+ estimate,
+ "request-1",
+ new IdempotencyKey("deduplication-1"),
+ snapshot,
+ TOKEN_ESTIMATE
+ )
+ ).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()
+ )
+ );
+ }
}