use std::io::Write;
use chrono::{DateTime, Duration, Utc};
use costroid_connect::{
ApiVendor, ConnectionRegistry, CostReportOutcome, CredentialStore, DateRange,
VendorReportUnavailable,
};
use costroid_core::{reconcile_cost, FocusRecord, LocalCostEstimate, Period};
use crate::connect::AdapterSet;
use crate::render::{self, RenderOptions};
#[allow(clippy::too_many_arguments)]
pub fn run_reconcile(
vendor_filter: Option<ApiVendor>,
window: DateRange,
rows: &[FocusRecord],
adapters: &dyn AdapterSet,
store: &CredentialStore,
registry: &ConnectionRegistry,
out: &mut dyn Write,
options: RenderOptions,
) -> anyhow::Result<i32> {
let vendors = vendors_in_scope(vendor_filter, store, registry)?;
let label = window_label(window);
if vendor_filter.is_none() && !vendors.iter().any(|vendor| *vendor != ApiVendor::Gemini) {
writeln!(
out,
"No billing vendor connected. Connect one with: costroid connect <anthropic|openai>\n"
)?;
}
for (index, vendor) in vendors.iter().enumerate() {
if index > 0 {
writeln!(out)?;
}
let scoped = scope_rows(rows, *vendor, window);
let local = LocalCostEstimate::from_focus_records(&scoped)?;
let outcome = match adapters.cost_report(*vendor, store, window) {
Ok(outcome) => outcome,
Err(_) => CostReportOutcome::Unavailable(VendorReportUnavailable::FetchFailed),
};
let recon = reconcile_cost(&local, &outcome);
let section = render::render_reconciliation(&vendor.to_string(), &label, &recon, options);
write!(out, "{section}")?;
}
Ok(0)
}
fn vendors_in_scope(
vendor_filter: Option<ApiVendor>,
store: &CredentialStore,
registry: &ConnectionRegistry,
) -> anyhow::Result<Vec<ApiVendor>> {
if let Some(vendor) = vendor_filter {
return Ok(vec![vendor]);
}
let mut vendors = Vec::new();
for vendor in [ApiVendor::Anthropic, ApiVendor::OpenAI] {
if registry.is_connected(vendor)? && store.retrieve(vendor)?.is_some() {
vendors.push(vendor);
}
}
vendors.push(ApiVendor::Gemini);
Ok(vendors)
}
fn scope_rows(rows: &[FocusRecord], vendor: ApiVendor, window: DateRange) -> Vec<FocusRecord> {
let tool = match vendor {
ApiVendor::Anthropic => "claude-code",
ApiVendor::OpenAI => "codex",
ApiVendor::Gemini => return Vec::new(),
};
rows.iter()
.filter(|row| row.x_tool == tool)
.filter(|row| {
row.charge_period_start >= window.start && row.charge_period_start < window.end
})
.cloned()
.collect()
}
pub fn completed_window(period: Period) -> DateRange {
const DAY: i64 = 86_400;
let days_back = match period {
Period::Day => 1,
Period::Week => 7,
Period::Month => 30,
Period::Year => 365,
};
let now = Utc::now();
let today_midnight = now.timestamp() - now.timestamp().rem_euclid(DAY);
match (
DateTime::<Utc>::from_timestamp(today_midnight - days_back * DAY, 0),
DateTime::<Utc>::from_timestamp(today_midnight, 0),
) {
(Some(start), Some(end)) => DateRange::new(start, end),
_ => DateRange::new(now - Duration::days(days_back), now),
}
}
fn window_label(window: DateRange) -> String {
let first = window.start.date_naive();
let last = (window.end - Duration::days(1)).date_naive();
if first >= last {
format!("{first} (UTC, completed day)")
} else {
format!("{first} to {last} (UTC, completed days)")
}
}
#[cfg(all(test, feature = "connect-test-support"))]
mod tests {
use super::*;
use costroid_connect::test_support::{
install_mock_keychain, ok_json, serve_sequence, MockServer,
};
use costroid_connect::{ConnectError, CostReportOutcome, OrgValidation, SecretString};
use costroid_focus::{FocusAccessPath, TokenType, UnpricedUsage};
use std::ffi::OsString;
use std::fs;
use std::path::PathBuf;
#[track_caller]
fn okv<T, E: std::fmt::Debug>(result: Result<T, E>) -> T {
match result {
Ok(value) => value,
Err(err) => panic!("expected Ok, got Err: {err:?}"),
}
}
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir()
.join(format!("costroid-t10c-{tag}-{}-{n}", std::process::id()));
let _ = fs::remove_dir_all(&path);
okv(fs::create_dir_all(&path));
Self { path }
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
struct LoopbackAdapters {
server: MockServer,
}
impl AdapterSet for LoopbackAdapters {
fn validate_anthropic(&self, key: &SecretString) -> Result<OrgValidation, ConnectError> {
self.server.anthropic_adapter().validate(key)
}
fn probe_openai(
&self,
key: &SecretString,
range: DateRange,
) -> Result<CostReportOutcome, ConnectError> {
self.server.openai_adapter().fetch_cost_report(key, range)
}
fn cost_report(
&self,
vendor: ApiVendor,
store: &CredentialStore,
range: DateRange,
) -> Result<CostReportOutcome, ConnectError> {
match vendor {
ApiVendor::Anthropic => self.server.anthropic_adapter().cost_report(store, range),
ApiVendor::OpenAI => self.server.openai_adapter().cost_report(store, range),
ApiVendor::Gemini => Ok(CostReportOutcome::Unavailable(
costroid_connect::VendorReportUnavailable::NoSanctionedStaticKeyApi,
)),
}
}
}
const LIVE_COST: &str = r#"{"data":[{"starting_at":"2026-06-14T00:00:00Z","ending_at":"2026-06-15T00:00:00Z","results":[
{"currency":"USD","amount":"0.0045","workspace_id":null,"description":"Input","cost_type":"tokens","context_window":"0-200k","model":"claude-haiku-4-5-20251001","service_tier":"standard","token_type":"uncached_input_tokens","inference_geo":"not_available"},
{"currency":"USD","amount":"0.047","workspace_id":null,"description":"Output","cost_type":"tokens","context_window":"0-200k","model":"claude-haiku-4-5-20251001","service_tier":"standard","token_type":"output_tokens","inference_geo":"not_available"}]}],"has_more":false,"next_page":null}"#;
fn utc(y: i32, m: u32, d: u32, h: u32) -> DateTime<Utc> {
match chrono::TimeZone::with_ymd_and_hms(&Utc, y, m, d, h, 0, 0) {
chrono::LocalResult::Single(value) => value,
_ => panic!("bad test instant"),
}
}
fn api_row(at: DateTime<Utc>, tool: &str, model: &str, billed: &str) -> FocusRecord {
let mut row = okv(FocusRecord::unpriced_usage(UnpricedUsage {
timestamp: at,
tool: tool.to_string(),
model: model.to_string(),
token_type: TokenType::Input,
token_count: 1_000,
project: None,
access_path: FocusAccessPath::Api,
service_name: "svc".to_string(),
service_provider_name: "prov".to_string(),
host_provider_name: "prov".to_string(),
invoice_issuer_name: "prov".to_string(),
billing_currency: "USD".to_string(),
}));
let cost = match rust_decimal::Decimal::from_str_exact(billed) {
Ok(value) => value,
Err(err) => panic!("bad billed {billed:?}: {err:?}"),
};
row.billed_cost = cost;
row.effective_cost = cost;
row
}
fn fixed_window() -> DateRange {
DateRange::new(utc(2026, 6, 14, 0), utc(2026, 6, 15, 0))
}
#[test]
fn reconcile_anthropic_fetches_loopback_scopes_rows_and_renders_no_real_network() {
install_mock_keychain();
let store = okv(CredentialStore::new());
let dir = TempDir::new("reconcile");
let reg_path = dir.path.join("connections.json");
let registry = ConnectionRegistry::at(reg_path.clone());
okv(store.store(
ApiVendor::Anthropic,
&SecretString::from("sk-ant-admin-FAKE-T10C".to_string()),
));
okv(registry.mark_connected(ApiVendor::Anthropic));
let rows = vec![
api_row(
utc(2026, 6, 14, 9),
"claude-code",
"claude-haiku-4-5-20251001",
"0.10",
),
api_row(utc(2026, 6, 14, 10), "cursor", "composer-2.5", "9.99"),
api_row(
utc(2026, 6, 1, 9),
"claude-code",
"claude-haiku-4-5-20251001",
"5.55",
),
];
let adapters = LoopbackAdapters {
server: serve_sequence(vec![ok_json(LIVE_COST)]),
};
let mut out = Vec::new();
let code = okv(run_reconcile(
Some(ApiVendor::Anthropic),
fixed_window(),
&rows,
&adapters,
&store,
®istry,
&mut out,
RenderOptions::plain(),
));
assert_eq!(code, 0);
let rendered = String::from_utf8_lossy(&out);
let request = okv(adapters.server.next_request());
assert!(
request.contains("GET /v1/organizations/cost_report"),
"the reconcile fetch must hit cost_report: {request}"
);
assert!(
rendered.contains("est ~$0.10"),
"scoped estimate: {rendered}"
);
assert!(rendered.contains("claude-haiku-4-5-20251001"));
assert!(
rendered.contains("over"),
"estimate exceeds invoice: {rendered}"
);
assert!(
!rendered.contains("9.99"),
"cursor must be excluded: {rendered}"
);
assert!(
!rendered.contains("5.55"),
"out-of-window row excluded: {rendered}"
);
assert!(
rendered.is_ascii(),
"plain reconcile must be ASCII: {rendered}"
);
let files: Vec<OsString> = okv(fs::read_dir(&dir.path))
.filter_map(Result::ok)
.map(|entry| entry.file_name())
.collect();
assert_eq!(files, vec![OsString::from("connections.json")]);
let raw = okv(fs::read_to_string(®_path));
assert!(
!raw.contains("sk-ant-admin"),
"no secret in registry: {raw}"
);
}
#[test]
fn reconcile_not_connected_surfaces_estimate_without_network() {
install_mock_keychain();
let store = okv(CredentialStore::new());
let dir = TempDir::new("reconcile-unconnected");
let registry = ConnectionRegistry::at(dir.path.join("connections.json"));
let rows = vec![api_row(
utc(2026, 6, 14, 9),
"claude-code",
"claude-opus-4-8",
"2.00",
)];
let adapters = LoopbackAdapters {
server: serve_sequence(vec![]),
};
let mut out = Vec::new();
let code = okv(run_reconcile(
Some(ApiVendor::Anthropic),
fixed_window(),
&rows,
&adapters,
&store,
®istry,
&mut out,
RenderOptions::plain(),
));
assert_eq!(code, 0);
let rendered = String::from_utf8_lossy(&out);
assert!(rendered.contains("vendor invoice unavailable: connect anthropic first"));
assert!(rendered.contains("est ~$2.00"));
assert!(!rendered.contains("over") && !rendered.contains("under"));
}
#[test]
fn reconcile_all_vendors_lists_connected_plus_gemini_unavailable() {
install_mock_keychain();
let store = okv(CredentialStore::new());
let dir = TempDir::new("reconcile-all");
let registry = ConnectionRegistry::at(dir.path.join("connections.json"));
let adapters = LoopbackAdapters {
server: serve_sequence(vec![]),
};
let mut out = Vec::new();
let code = okv(run_reconcile(
None,
fixed_window(),
&[],
&adapters,
&store,
®istry,
&mut out,
RenderOptions::plain(),
));
assert_eq!(code, 0);
let rendered = String::from_utf8_lossy(&out);
assert!(rendered.contains("No billing vendor connected"));
assert!(rendered.contains("gemini"));
assert!(rendered.contains(
costroid_core::GEMINI_UNAVAILABLE_MESSAGE
.replace('—', "-")
.as_str()
));
assert_eq!(
rendered.matches("estimate vs invoice").count(),
1,
"only the gemini section should render: {rendered}"
);
}
#[test]
fn reconcile_degrades_one_vendor_fetch_failure_and_still_renders_the_others() {
install_mock_keychain();
let store = okv(CredentialStore::new());
let dir = TempDir::new("reconcile-degrade");
let registry = ConnectionRegistry::at(dir.path.join("connections.json"));
okv(store.store(
ApiVendor::Anthropic,
&SecretString::from("sk-ant-admin-FAKE".to_string()),
));
okv(store.store(
ApiVendor::OpenAI,
&SecretString::from("sk-admin-FAKE".to_string()),
));
okv(registry.mark_connected(ApiVendor::Anthropic));
okv(registry.mark_connected(ApiVendor::OpenAI));
let rows = vec![
api_row(
utc(2026, 6, 14, 9),
"claude-code",
"claude-haiku-4-5-20251001",
"0.10",
),
api_row(utc(2026, 6, 14, 9), "codex", "gpt-5.5", "0.20"),
];
const BAD_BODY: &str = "this is definitely not json";
const EMPTY_COSTS: &str =
r#"{"object":"page","has_more":false,"next_page":null,"data":[]}"#;
let adapters = LoopbackAdapters {
server: serve_sequence(vec![ok_json(BAD_BODY), ok_json(EMPTY_COSTS)]),
};
let mut out = Vec::new();
let code = okv(run_reconcile(
None,
fixed_window(),
&rows,
&adapters,
&store,
®istry,
&mut out,
RenderOptions::plain(),
));
assert_eq!(code, 0);
let rendered = String::from_utf8_lossy(&out);
assert!(
rendered.contains("the invoice request could not be completed"),
"anthropic fetch failure degrades (does not abort): {rendered}"
);
assert!(
rendered.contains("est ~$0.10"),
"anthropic local estimate still surfaces under a fetch failure: {rendered}"
);
assert!(
rendered.contains("est ~$0.20"),
"the other vendor still reconciles: {rendered}"
);
assert!(
rendered.contains("gemini"),
"gemini section present: {rendered}"
);
assert!(
!rendered.contains("sk-ant-admin") && !rendered.contains("sk-admin"),
"no secret in degraded output: {rendered}"
);
assert!(
rendered.is_ascii(),
"plain reconcile must be ASCII: {rendered}"
);
}
#[test]
fn completed_window_excludes_todays_incomplete_utc_day() {
for (period, days) in [
(Period::Day, 1),
(Period::Week, 7),
(Period::Month, 30),
(Period::Year, 365),
] {
let window = completed_window(period);
assert!(window.end <= Utc::now(), "{period:?} end excludes today");
assert_eq!(
window.end - window.start,
Duration::days(days),
"{period:?} spans {days} completed days"
);
assert_eq!(
window.end.timestamp() % 86_400,
0,
"{period:?} end is UTC midnight"
);
}
}
#[test]
fn window_label_distinguishes_a_single_day_from_a_span() {
let single = window_label(DateRange::new(utc(2026, 6, 14, 0), utc(2026, 6, 15, 0)));
assert_eq!(single, "2026-06-14 (UTC, completed day)");
let span = window_label(DateRange::new(utc(2026, 6, 8, 0), utc(2026, 6, 15, 0)));
assert_eq!(span, "2026-06-08 to 2026-06-14 (UTC, completed days)");
}
}