feat: reporting-allocate AI-plan cost across work

Split a booked AI-plan fact across workplans or repos using measured
tokens, measured+estimated tokens, session counts, or an even split.
Unmeasured remainder stays a residual. The record is not resource-control
AllocationEvidence and does not create booked spend.
This commit is contained in:
tegwick 2026-08-15 19:35:43 +02:00
parent 8916f3c4d0
commit efd1b31001
10 changed files with 646 additions and 3 deletions

View file

@ -31,6 +31,8 @@ uv run finhub ledger set-entitlement --provider anthropic --plan claude-max --pe
uv run finhub ledger plan-month --period 2026-08 uv run finhub ledger plan-month --period 2026-08
uv run finhub ledger ingest-session-tokens tests/fixtures/session-tokens-2026-08.json uv run finhub ledger ingest-session-tokens tests/fixtures/session-tokens-2026-08.json
uv run finhub ledger session-tokens --period 2026-08 uv run finhub ledger session-tokens --period 2026-08
uv run finhub ledger allocate-plan --fact FACT --method measured_token_share
uv run finhub ledger plan-allocations
uv run finhub ledger summary uv run finhub ledger summary
# CLI — scheduled evaluation (cron/systemd friendly) # CLI — scheduled evaluation (cron/systemd friendly)

View file

@ -28,6 +28,8 @@ uv run finhub ledger set-entitlement --provider anthropic --plan claude-max --pe
uv run finhub ledger plan-month --period 2026-08 uv run finhub ledger plan-month --period 2026-08
uv run finhub ledger ingest-session-tokens tests/fixtures/session-tokens-2026-08.json uv run finhub ledger ingest-session-tokens tests/fixtures/session-tokens-2026-08.json
uv run finhub ledger session-tokens --period 2026-08 uv run finhub ledger session-tokens --period 2026-08
uv run finhub ledger allocate-plan --fact FACT --method measured_token_share
uv run finhub ledger plan-allocations
uv run finhub ledger set-price --client acme --application portal --instance prod-01 --period 2026-07 --amount 100 --source agreement-2026-01 uv run finhub ledger set-price --client acme --application portal --instance prod-01 --period 2026-07 --amount 100 --source agreement-2026-01
uv run finhub ledger margins uv run finhub ledger margins
uv run finhub ledger allocations uv run finhub ledger allocations

View file

@ -19,6 +19,10 @@ from fin_hub.services.alerts import evaluate_budget_alerts
from fin_hub.services.allocation import shared_cost_allocations from fin_hub.services.allocation import shared_cost_allocations
from fin_hub.services.billing import build_billing_basis from fin_hub.services.billing import build_billing_basis
from fin_hub.services.evaluate import evaluate_runway from fin_hub.services.evaluate import evaluate_runway
from fin_hub.services.reporting_allocation import (
allocate_ai_plan_report,
list_ai_plan_allocations,
)
from fin_hub.services.session_tokens import ( from fin_hub.services.session_tokens import (
ingest_session_token_file, ingest_session_token_file,
list_current_session_token_evidence, list_current_session_token_evidence,
@ -231,6 +235,25 @@ def _cmd_ledger_session_tokens(args: argparse.Namespace) -> int:
return 0 return 0
def _cmd_ledger_allocate_plan(args: argparse.Namespace) -> int:
report = allocate_ai_plan_report(
args.fact,
method=args.method,
estimator_version=args.estimator_version,
session_token_record_id=args.session_tokens,
target_scope=args.scope,
ledger_path=_ledger_path(args),
)
print(json.dumps(report.as_dict(), indent=2, default=str))
return 0
def _cmd_ledger_plan_allocations(args: argparse.Namespace) -> int:
reports = list_ai_plan_allocations(ledger_path=_ledger_path(args))
print(json.dumps([report.as_dict() for report in reports], indent=2, default=str))
return 0
def _cmd_ledger_allocations(args: argparse.Namespace) -> int: def _cmd_ledger_allocations(args: argparse.Namespace) -> int:
reports = shared_cost_allocations(ledger_path=_ledger_path(args)) reports = shared_cost_allocations(ledger_path=_ledger_path(args))
print(json.dumps([report.as_dict() for report in reports], indent=2, default=str)) print(json.dumps([report.as_dict() for report in reports], indent=2, default=str))
@ -423,6 +446,36 @@ def build_parser() -> argparse.ArgumentParser:
ledger_session_tokens.add_argument("--ledger", help="Ledger database path") ledger_session_tokens.add_argument("--ledger", help="Ledger database path")
ledger_session_tokens.set_defaults(func=_cmd_ledger_session_tokens) ledger_session_tokens.set_defaults(func=_cmd_ledger_session_tokens)
ledger_allocate_plan = ledger_sub.add_parser(
"allocate-plan",
help="Reporting-allocate a booked AI-plan fact across work (not RC allocation)",
)
ledger_allocate_plan.add_argument("--fact", required=True, help="financial_fact_id")
ledger_allocate_plan.add_argument(
"--method",
required=True,
choices=[
"measured_token_share",
"measured_estimated_token_share",
"session_count_share",
"even_split",
],
)
ledger_allocate_plan.add_argument("--estimator-version", default="0.1")
ledger_allocate_plan.add_argument("--session-tokens", help="session_token_evidence record_id")
ledger_allocate_plan.add_argument(
"--scope", default="workplan", choices=["workplan", "repo"]
)
ledger_allocate_plan.add_argument("--ledger", help="Ledger database path")
ledger_allocate_plan.set_defaults(func=_cmd_ledger_allocate_plan)
ledger_plan_allocations = ledger_sub.add_parser(
"plan-allocations",
help="List current AI-plan reporting allocations",
)
ledger_plan_allocations.add_argument("--ledger", help="Ledger database path")
ledger_plan_allocations.set_defaults(func=_cmd_ledger_plan_allocations)
ledger_allocations = ledger_sub.add_parser( ledger_allocations = ledger_sub.add_parser(
"allocations", "allocations",
help="Reconcile resource-control allocation evidence to booked facts", help="Reconcile resource-control allocation evidence to booked facts",

View file

@ -0,0 +1,78 @@
"""Fin-hub reporting allocation of a booked AI-plan fact onto work."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from fin_hub.money import money
REPORTING_METHODS = (
"measured_token_share",
"measured_estimated_token_share",
"session_count_share",
"even_split",
)
class ReportingAllocationShare(BaseModel):
model_config = ConfigDict(extra="forbid")
target_key: str = Field(min_length=1, max_length=256)
share: Decimal = Field(ge=0, le=1)
driver_value: int = Field(ge=0)
@field_validator("share", mode="before")
@classmethod
def validate_share(cls, value):
return money(value, non_negative=True)
class ReportingAllocation(BaseModel):
"""Local reporting allocation. Not resource-control AllocationEvidence."""
model_config = ConfigDict(extra="forbid")
schema_version: Literal["0.1"] = "0.1"
record_type: Literal["reporting_allocation"] = "reporting_allocation"
record_id: str = Field(min_length=1, max_length=256)
revision_of: str | None = None
financial_fact_id: str = Field(min_length=1)
method: Literal[
"measured_token_share",
"measured_estimated_token_share",
"session_count_share",
"even_split",
]
estimator_version: str = Field(min_length=1, max_length=64)
session_token_record_id: str | None = None
period_start: date
period_end: date
currency: str
booked_amount: Decimal
shares: list[ReportingAllocationShare]
residual_share: Decimal = Field(ge=0, le=1)
residual_reason: Literal["none", "unmeasured", "unattributed", "no_targets"]
source_evidence: list[str] = Field(min_length=1)
@field_validator("booked_amount", "residual_share", mode="before")
@classmethod
def validate_money_fields(cls, value):
return money(value, non_negative=True)
@model_validator(mode="after")
def validate_shares(self) -> "ReportingAllocation":
keys = [share.target_key for share in self.shares]
if len(set(keys)) != len(keys):
raise ValueError("reporting allocation target keys must be unique")
total = sum((share.share for share in self.shares), Decimal("0.00"))
if total + self.residual_share != Decimal("1.00"):
raise ValueError("allocation shares plus residual_share must equal 1")
if self.residual_share == Decimal("0.00") and self.residual_reason != "none":
raise ValueError("zero residual must use residual_reason=none")
if self.residual_share > Decimal("0.00") and self.residual_reason == "none":
raise ValueError("nonzero residual requires a residual_reason")
return self

View file

@ -232,9 +232,12 @@ def ingest_planning_evidence(
) -> PlanningEvidence: ) -> PlanningEvidence:
"""Validate and idempotently retain planning evidence outside booked spend.""" """Validate and idempotently retain planning evidence outside booked spend."""
if payload.get("record_type") == "session_token_evidence": if payload.get("record_type") in {
"session_token_evidence",
"reporting_allocation",
}:
raise ValueError( raise ValueError(
"session_token_evidence is not resource-control planning evidence" f"{payload.get('record_type')} is not resource-control planning evidence"
) )
ledger = ledger_path or default_ledger_path() ledger = ledger_path or default_ledger_path()
with _connect(ledger) as conn: with _connect(ledger) as conn:

View file

@ -286,6 +286,17 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
recorded_at TEXT NOT NULL recorded_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS reporting_allocations (
record_id TEXT PRIMARY KEY,
revision_of TEXT,
financial_fact_id TEXT NOT NULL,
method TEXT NOT NULL,
estimator_version TEXT NOT NULL,
payload_json TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1,
recorded_at TEXT NOT NULL
);
""" """
) )
columns = {row["name"] for row in conn.execute("PRAGMA table_info(ledger_entries)")} columns = {row["name"] for row in conn.execute("PRAGMA table_info(ledger_entries)")}

View file

@ -0,0 +1,343 @@
"""Allocate a booked AI-plan fact across work as a reporting overlay."""
from __future__ import annotations
import calendar
import json
from dataclasses import asdict, dataclass
from datetime import date
from decimal import Decimal
from pathlib import Path
from typing import Literal
from fin_hub.money import MONEY_QUANTUM, minor_money, money, money_minor
from fin_hub.schemas.reporting_allocation import (
REPORTING_METHODS,
ReportingAllocation,
ReportingAllocationShare,
)
from fin_hub.schemas.session_tokens import SessionTokenAssociation, SessionTokenEvidence
from fin_hub.services.allocation import AllocatedTarget, _split_minor_units
from fin_hub.services.ledger import _connect, _utc_now, default_ledger_path
from fin_hub.services.session_tokens import list_current_session_token_evidence
METHOD_KINDS: dict[str, frozenset[str]] = {
"measured_token_share": frozenset({"measured"}),
"measured_estimated_token_share": frozenset({"measured", "estimated"}),
"session_count_share": frozenset({"measured", "allocated", "estimated"}),
"even_split": frozenset({"measured", "allocated", "estimated"}),
}
def _ensure_reporting_allocation_schema(conn) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS reporting_allocations (
record_id TEXT PRIMARY KEY,
revision_of TEXT,
financial_fact_id TEXT NOT NULL,
method TEXT NOT NULL,
estimator_version TEXT NOT NULL,
payload_json TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1,
recorded_at TEXT NOT NULL
)
"""
)
conn.commit()
def _month_bounds(period_month: str) -> tuple[date, date]:
year, month = (int(part) for part in period_month.split("-"))
return date(year, month, 1), date(year, month, calendar.monthrange(year, month)[1])
def _overlaps(record: SessionTokenEvidence, start: date, end: date) -> bool:
return record.period_start <= end and record.period_end >= start
def _driver(association: SessionTokenAssociation, method: str) -> int:
if method == "session_count_share":
return association.event_count
return association.tokens_in + association.tokens_out
def _pool(record: SessionTokenEvidence, method: str) -> int:
kinds = METHOD_KINDS[method]
if method == "session_count_share":
return sum(
slice_.event_count
for kind, slice_ in record.totals.items()
if kind in kinds
)
return sum(
slice_.tokens_in + slice_.tokens_out
for kind, slice_ in record.totals.items()
if kind in kinds and slice_.event_count > 0
)
def _select_token_record(
fact_start: date,
fact_end: date,
*,
session_token_record_id: str | None,
ledger_path: Path,
) -> SessionTokenEvidence | None:
records = list_current_session_token_evidence(ledger_path=ledger_path)
if session_token_record_id is not None:
matches = [row for row in records if row.record_id == session_token_record_id]
if len(matches) != 1:
raise ValueError(
f"session_token_record_id {session_token_record_id} is not current"
)
return matches[0]
overlapping = [row for row in records if _overlaps(row, fact_start, fact_end)]
if len(overlapping) > 1:
raise ValueError("multiple session-token records overlap this fact; pass an id")
return overlapping[0] if overlapping else None
def _compute_shares(
*,
method: str,
target_scope: Literal["workplan", "repo"],
token_record: SessionTokenEvidence | None,
) -> tuple[list[ReportingAllocationShare], Decimal, str]:
if method == "even_split" and token_record is None:
return [], Decimal("1.00"), "no_targets"
if token_record is None:
return [], Decimal("1.00"), "unmeasured"
kinds = METHOD_KINDS[method]
grouped: dict[str, int] = {}
for association in token_record.associations:
if association.scope != target_scope or association.measurement_kind not in kinds:
continue
key = f"{association.scope}:{association.scope_id}"
grouped[key] = grouped.get(key, 0) + _driver(association, method)
if method == "even_split":
if not grouped:
return [], Decimal("1.00"), "no_targets"
share = money(Decimal("1") / Decimal(len(grouped)))
shares = [
ReportingAllocationShare(target_key=key, share=share, driver_value=1)
for key in sorted(grouped)
]
assigned = sum((item.share for item in shares), Decimal("0.00"))
residual = money(Decimal("1.00") - assigned)
return shares, residual, "none" if residual == Decimal("0.00") else "unattributed"
pool = _pool(token_record, method)
associated = sum(grouped.values())
if pool == 0:
return [], Decimal("1.00"), "unmeasured"
if associated > pool:
raise ValueError("association drivers exceed the session-token pool")
shares = [
ReportingAllocationShare(
target_key=key,
share=money(Decimal(value) / Decimal(pool)),
driver_value=value,
)
for key, value in sorted(grouped.items())
if value > 0
]
assigned = sum((item.share for item in shares), Decimal("0.00"))
residual = money(Decimal("1.00") - assigned)
if residual == Decimal("0.00"):
reason = "none"
elif associated == 0:
reason = "unattributed"
else:
reason = "unmeasured"
return shares, residual, reason
@dataclass(frozen=True)
class ReportingAllocationReport:
record_id: str
revision_of: str | None
financial_fact_id: str
method: str
estimator_version: str
session_token_record_id: str | None
period_start: date
period_end: date
currency: str
booked_amount: Decimal
targets: tuple[AllocatedTarget, ...]
residual_share: Decimal
residual_amount: Decimal
residual_reason: str
source_evidence: tuple[str, ...]
def as_dict(self) -> dict:
return asdict(self)
def _to_report(record: ReportingAllocation) -> ReportingAllocationReport:
residual_key = "__residual__"
split = _split_minor_units(
money_minor(record.booked_amount),
[
*[(share.target_key, share.share) for share in record.shares],
(residual_key, record.residual_share),
],
)
return ReportingAllocationReport(
record_id=record.record_id,
revision_of=record.revision_of,
financial_fact_id=record.financial_fact_id,
method=record.method,
estimator_version=record.estimator_version,
session_token_record_id=record.session_token_record_id,
period_start=record.period_start,
period_end=record.period_end,
currency=record.currency,
booked_amount=record.booked_amount,
targets=tuple(
AllocatedTarget(
target_key=share.target_key,
share=share.share,
amount=minor_money(split[share.target_key][0]),
rounding_adjustment=split[share.target_key][1] * MONEY_QUANTUM,
)
for share in record.shares
),
residual_share=record.residual_share,
residual_amount=minor_money(split[residual_key][0]),
residual_reason=record.residual_reason,
source_evidence=tuple(record.source_evidence),
)
def allocate_ai_plan_report(
financial_fact_id: str,
*,
method: str,
estimator_version: str = "0.1",
session_token_record_id: str | None = None,
target_scope: Literal["workplan", "repo"] = "workplan",
ledger_path: Path | None = None,
) -> ReportingAllocationReport:
if method not in REPORTING_METHODS:
raise ValueError(
"method must be measured_token_share, measured_estimated_token_share, "
"session_count_share, or even_split"
)
ledger = ledger_path or default_ledger_path()
with _connect(ledger) as conn:
_ensure_reporting_allocation_schema(conn)
fact = conn.execute(
"SELECT * FROM ledger_entries "
"WHERE financial_fact_id = ? AND is_current = 1",
(financial_fact_id,),
).fetchone()
if fact is None:
raise ValueError("financial_fact_id must reference a current fact")
if fact["source_type"] != "ai-plan":
raise ValueError("reporting allocation only applies to ai-plan booked facts")
period_month = fact["period_month"]
currency = fact["currency"]
booked_minor = int(fact["amount_minor"])
fact_start, fact_end = _month_bounds(period_month)
token_record = _select_token_record(
fact_start,
fact_end,
session_token_record_id=session_token_record_id,
ledger_path=ledger,
)
shares, residual_share, residual_reason = _compute_shares(
method=method,
target_scope=target_scope,
token_record=token_record,
)
source_evidence = [f"ledger:{financial_fact_id}"]
if token_record is not None:
source_evidence.extend(token_record.source_evidence)
token_id = None if token_record is None else token_record.record_id
record_id = (
f"reporting-alloc:{financial_fact_id}:{method}:{estimator_version}"
f":{token_id or 'none'}"
)
payload = ReportingAllocation(
record_id=record_id,
financial_fact_id=financial_fact_id,
method=method, # type: ignore[arg-type]
estimator_version=estimator_version,
session_token_record_id=token_id,
period_start=fact_start,
period_end=fact_end,
currency=currency,
booked_amount=minor_money(booked_minor),
shares=shares,
residual_share=residual_share,
residual_reason=residual_reason, # type: ignore[arg-type]
source_evidence=source_evidence,
)
canonical = payload.model_dump_json()
with _connect(ledger) as conn:
_ensure_reporting_allocation_schema(conn)
conn.execute("BEGIN IMMEDIATE")
existing = conn.execute(
"SELECT payload_json, is_current FROM reporting_allocations WHERE record_id = ?",
(record_id,),
).fetchone()
if existing is not None:
if json.loads(existing["payload_json"]) != json.loads(canonical):
raise ValueError("record_id already exists with different content")
return _to_report(payload)
current = conn.execute(
"SELECT record_id FROM reporting_allocations "
"WHERE financial_fact_id = ? AND method = ? AND estimator_version = ? "
"AND is_current = 1",
(financial_fact_id, method, estimator_version),
).fetchone()
revision_of = None
if current is not None:
revision_of = current["record_id"]
payload = payload.model_copy(update={"revision_of": revision_of})
canonical = payload.model_dump_json()
conn.execute(
"UPDATE reporting_allocations SET is_current = 0 WHERE record_id = ?",
(revision_of,),
)
conn.execute(
"""
INSERT INTO reporting_allocations (
record_id, revision_of, financial_fact_id, method, estimator_version,
payload_json, is_current, recorded_at
) VALUES (?, ?, ?, ?, ?, ?, 1, ?)
""",
(
payload.record_id,
revision_of,
financial_fact_id,
method,
estimator_version,
canonical,
_utc_now(),
),
)
conn.commit()
return _to_report(payload)
def list_ai_plan_allocations(
*,
ledger_path: Path | None = None,
) -> list[ReportingAllocationReport]:
ledger = ledger_path or default_ledger_path()
with _connect(ledger) as conn:
_ensure_reporting_allocation_schema(conn)
rows = conn.execute(
"SELECT payload_json FROM reporting_allocations "
"WHERE is_current = 1 ORDER BY record_id"
).fetchall()
return [
_to_report(ReportingAllocation.model_validate_json(row["payload_json"]))
for row in rows
]

View file

@ -0,0 +1,47 @@
{
"schema_version": "0.1",
"record_type": "session_token_evidence",
"record_id": "session-tokens:state-hub:2026-08:two-workplans",
"revision_of": null,
"source_system": "state-hub",
"period_start": "2026-08-01",
"period_end": "2026-08-31",
"captured_at": "2026-08-15T12:00:00Z",
"source_evidence": [
"state-hub:/token-events/aggregate/?month=2026-08"
],
"totals": {
"measured": {
"tokens_in": 200,
"tokens_out": 0,
"event_count": 3,
"confidence": "1.0"
},
"estimated": {
"tokens_in": 50,
"tokens_out": 0,
"event_count": 1,
"confidence": "0.35"
}
},
"associations": [
{
"scope": "workplan",
"scope_id": "workplan-a",
"label": "WP-A",
"measurement_kind": "measured",
"tokens_in": 100,
"tokens_out": 0,
"event_count": 1
},
{
"scope": "workplan",
"scope_id": "workplan-b",
"label": "WP-B",
"measurement_kind": "measured",
"tokens_in": 50,
"tokens_out": 0,
"event_count": 1
}
]
}

View file

@ -0,0 +1,98 @@
import sqlite3
from decimal import Decimal
from pathlib import Path
import pytest
from fin_hub.services.allocation import shared_cost_allocations
from fin_hub.services.exchange import ingest_planning_evidence
from fin_hub.services.ledger import import_csv, monthly_summary
from fin_hub.services.reporting_allocation import (
allocate_ai_plan_report,
list_ai_plan_allocations,
)
from fin_hub.services.session_tokens import ingest_session_token_file
FIXTURES = Path(__file__).parent / "fixtures"
def _claude_fact(tmp_path: Path) -> tuple[Path, str]:
ledger = tmp_path / "ledger.db"
import_csv(FIXTURES / "ai-plans.csv", "ai-plan", ledger_path=ledger)
with sqlite3.connect(ledger) as conn:
fact_id = conn.execute(
"SELECT financial_fact_id FROM ledger_entries "
"WHERE source_type = 'ai-plan' AND label = 'claude-max' AND is_current = 1"
).fetchone()[0]
return ledger, fact_id
def test_measured_token_share_two_workplans_keeps_unmeasured_residual(tmp_path: Path):
ledger, fact_id = _claude_fact(tmp_path)
ingest_session_token_file(
FIXTURES / "session-tokens-two-workplans.json", ledger_path=ledger
)
report = allocate_ai_plan_report(
fact_id, method="measured_token_share", ledger_path=ledger
)
by_key = {target.target_key: target for target in report.targets}
assert report.booked_amount == Decimal("200.00")
assert report.method == "measured_token_share"
assert report.residual_reason == "unmeasured"
assert report.residual_share == Decimal("0.25")
assert report.residual_amount == Decimal("50.00")
assert by_key["workplan:workplan-a"].amount == Decimal("100.00")
assert by_key["workplan:workplan-b"].amount == Decimal("50.00")
assert sum((target.amount for target in report.targets), report.residual_amount) == Decimal(
"200.00"
)
assert monthly_summary(ledger_path=ledger)[0].total == pytest.approx(240.0)
assert shared_cost_allocations(ledger_path=ledger) == []
assert list_ai_plan_allocations(ledger_path=ledger)[0].record_id == report.record_id
def test_even_split_does_not_use_token_weights(tmp_path: Path):
ledger, fact_id = _claude_fact(tmp_path)
ingest_session_token_file(
FIXTURES / "session-tokens-two-workplans.json", ledger_path=ledger
)
report = allocate_ai_plan_report(fact_id, method="even_split", ledger_path=ledger)
amounts = sorted(target.amount for target in report.targets)
assert amounts == [Decimal("100.00"), Decimal("100.00")]
assert report.residual_share == Decimal("0.00")
assert report.residual_reason == "none"
def test_reporting_allocation_is_not_planning_allocation(tmp_path: Path):
ledger, fact_id = _claude_fact(tmp_path)
report = allocate_ai_plan_report(fact_id, method="even_split", ledger_path=ledger)
with pytest.raises(ValueError, match="not resource-control"):
ingest_planning_evidence(
{
"record_type": "reporting_allocation",
"record_id": report.record_id,
"financial_fact_id": fact_id,
"method": "even_split",
},
ledger_path=ledger,
)
with sqlite3.connect(ledger) as conn:
planning = conn.execute(
"SELECT count(*) FROM sqlite_master WHERE name = 'planning_evidence'"
).fetchone()[0]
if planning:
stored = conn.execute(
"SELECT record_type FROM planning_evidence"
).fetchall()
assert stored == []
def test_non_ai_plan_fact_is_rejected(tmp_path: Path):
ledger = tmp_path / "ledger.db"
import_csv(FIXTURES / "cloud-costs.csv", "cloud", ledger_path=ledger)
with sqlite3.connect(ledger) as conn:
fact_id = conn.execute(
"SELECT financial_fact_id FROM ledger_entries WHERE is_current = 1"
).fetchone()[0]
with pytest.raises(ValueError, match="ai-plan"):
allocate_ai_plan_report(fact_id, method="even_split", ledger_path=ledger)

View file

@ -275,7 +275,7 @@ slices require a method. No session files are parsed and no
```task ```task
id: FIN-WP-0007-T05 id: FIN-WP-0007-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "db098143-90b7-475c-8846-e4c274e50b4a" state_hub_task_id: "db098143-90b7-475c-8846-e4c274e50b4a"
``` ```
@ -299,6 +299,12 @@ Done when a one-plan, two-workplan fixture reconciles to the booked
effective amount, names its method, keeps residual when coverage is effective amount, names its method, keeps residual when coverage is
incomplete, and is not stored as `AllocationEvidence`. incomplete, and is not stored as `AllocationEvidence`.
Completed 2026-08-15: `ledger allocate-plan` writes a reporting
allocation for an `ai-plan` fact. One plan / two workplans at 100 and
50 measured tokens of a 200-token pool leaves a 0.25 unmeasured
residual and still sums to the booked €200. The record is not stored
as `AllocationEvidence` and does not change booked totals.
## Publish an effectiveness report ## Publish an effectiveness report
```task ```task