diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index ab5efbef8a..06ceb78be0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -126,6 +126,24 @@ fn default_cost_period() -> String { /// otherwise falling back to the currency-code prefix. Used by tray surfaces /// that render a spend amount without a rate-window percent (MonthlyPlan). pub(crate) fn format_cost_amount(cost: &CostSnapshotBridge) -> String { + if let Some((amount, currency)) = + crate::commands::convert_preferred_amount(cost.used, &cost.currency_code) + { + let symbol = match currency.as_str() { + "USD" => Some("$"), + "EUR" => Some("€"), + "GBP" => Some("£"), + "TRY" => Some("₺"), + _ => None, + }; + return symbol.map_or_else( + || format!("{amount:.2} {currency}"), + |symbol| format!("{symbol}{amount:.2}"), + ); + } + if !cost.formatted_used.is_empty() { + return cost.formatted_used.clone(); + } if let Some(ref symbol) = cost.currency_symbol { format!("{}{:.2}", symbol, cost.used) } else { @@ -641,6 +659,7 @@ pub struct ProviderCatalogEntry { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SettingsSnapshot { + preferred_currency_code: String, enabled_providers: Vec, provider_order: Vec, refresh_interval_secs: u64, @@ -763,6 +782,7 @@ impl From for SettingsSnapshot { .collect(); Self { + preferred_currency_code: settings.preferred_currency_code, enabled_providers, provider_order, refresh_interval_secs: settings.refresh_interval_secs, diff --git a/apps/desktop-tauri/src-tauri/src/commands/currency.rs b/apps/desktop-tauri/src-tauri/src/commands/currency.rs new file mode 100644 index 0000000000..5eae3cd246 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/currency.rs @@ -0,0 +1,185 @@ +use std::{collections::HashMap, path::PathBuf}; + +use codexbar::currency::{ + SUPPORTED_CURRENCY_CODES, convert_amount, fallback_rates, fetch_exchange_rates, + normalize_preferred_currency, +}; +use serde::{Deserialize, Serialize}; +use tauri::State; +use tokio::sync::Mutex; + +const CACHE_MAX_AGE_SECS: i64 = 24 * 60 * 60; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PersistedRates { + fetched_at_unix: i64, + rates: HashMap, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CurrencyRatesSnapshot { + pub rates: HashMap, +} + +#[derive(Default)] +pub struct CurrencyRateCache { + inner: Mutex, +} + +#[derive(Default)] +struct CacheState { + loaded: bool, + persisted: Option, +} + +#[tauri::command] +pub async fn get_currency_rates( + app: tauri::AppHandle, + cache: State<'_, CurrencyRateCache>, + preferred_currency_code: String, +) -> Result { + let preferred = normalize_preferred_currency(&preferred_currency_code); + let mut state = cache.inner.lock().await; + if !state.loaded { + state.persisted = read_persisted_rates(); + state.loaded = true; + } + + if preferred != "AUTO" { + let now = unix_now(); + let fresh = state.persisted.as_ref().is_some_and(|cached| { + let age = now.saturating_sub(cached.fetched_at_unix); + (0..CACHE_MAX_AGE_SECS).contains(&age) + }); + if !fresh { + match fetch_exchange_rates().await { + Ok(rates) => { + let entry = PersistedRates { + fetched_at_unix: now, + rates, + }; + persist_rates(&entry); + state.persisted = Some(entry); + crate::tray_bridge::refresh_tray_presentation(&app); + } + Err(error) => { + tracing::debug!(%error, "currency rates unavailable; using cached or offline rates") + } + } + } + } + + Ok(CurrencyRatesSnapshot { + rates: merged_rates(state.persisted.as_ref()), + }) +} + +pub(crate) fn convert_preferred_amount(amount: f64, source_code: &str) -> Option<(f64, String)> { + let preferred = + normalize_preferred_currency(&codexbar::settings::Settings::load().preferred_currency_code); + if preferred == "AUTO" { + return None; + } + let cached = read_persisted_rates(); + let rates = merged_rates(cached.as_ref()); + convert_amount(amount, source_code, &preferred, &rates).map(|converted| (converted, preferred)) +} + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs().min(i64::MAX as u64) as i64) + .unwrap_or_default() +} + +fn cache_path() -> Option { + codexbar::settings::Settings::settings_path()? + .parent() + .map(|parent| parent.join("currency-rates.json")) +} + +fn clean_rates(rates: HashMap) -> HashMap { + let mut clean = HashMap::new(); + for code in SUPPORTED_CURRENCY_CODES { + if let Some(rate) = rates.get(*code).copied() + && rate.is_finite() + && rate > 0.0 + { + clean.insert((*code).to_string(), rate); + } + } + if clean + .get("USD") + .is_none_or(|rate| (*rate - 1.0).abs() > f64::EPSILON) + { + return HashMap::new(); + } + clean +} + +fn read_persisted_rates() -> Option { + let path = cache_path()?; + let bytes = std::fs::read(path).ok()?; + let mut cached: PersistedRates = serde_json::from_slice(&bytes).ok()?; + cached.rates = clean_rates(cached.rates); + (!cached.rates.is_empty()).then_some(cached) +} + +fn merged_rates(cached: Option<&PersistedRates>) -> HashMap { + let mut rates = fallback_rates(); + if let Some(cached) = cached { + for (code, rate) in clean_rates(cached.rates.clone()) { + rates.insert(code, rate); + } + } + rates +} + +fn persist_rates(cached: &PersistedRates) { + let Some(path) = cache_path() else { return }; + let Some(parent) = path.parent() else { return }; + let write_result = (|| -> Result<(), Box> { + std::fs::create_dir_all(parent)?; + let bytes = serde_json::to_vec(cached)?; + codexbar::atomic_file::write_atomic(&path, &bytes)?; + Ok(()) + })(); + if let Err(error) = write_result { + tracing::debug!(%error, "could not persist currency rate cache"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codexbar::currency::FALLBACK_RATES; + + #[test] + fn fallback_table_covers_every_preferred_currency() { + let rates = fallback_rates(); + assert_eq!(rates.len(), FALLBACK_RATES.len()); + for code in SUPPORTED_CURRENCY_CODES { + assert!( + rates + .get(*code) + .is_some_and(|rate| rate.is_finite() && *rate > 0.0) + ); + } + } + + #[test] + fn persisted_rates_are_sanitized_before_merging() { + let rates = HashMap::from([ + ("USD".to_string(), 1.0), + ("TRY".to_string(), 48.0), + ("EUR".to_string(), f64::NAN), + ("BTC".to_string(), 100.0), + ]); + let clean = clean_rates(rates); + assert_eq!(clean.get("TRY"), Some(&48.0)); + assert!(!clean.contains_key("EUR")); + assert!(!clean.contains_key("BTC")); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index fdfaeda967..fda0557740 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -24,6 +24,7 @@ use crate::surface::SurfaceMode; use crate::surface_target::SurfaceTarget; mod chart; +mod currency; mod spend_contract; mod tokens; mod updater; @@ -79,6 +80,7 @@ pub(crate) use usage_items::*; mod tests; pub use chart::*; +pub use currency::*; pub use spend_contract::*; pub use tokens::*; pub use updater::*; diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index df0e6621a9..5369b8e5e6 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -7,6 +7,7 @@ use super::*; #[derive(Debug, Clone, Default, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct SettingsUpdate { + pub preferred_currency_code: Option, pub enabled_providers: Option>, pub refresh_interval_secs: Option, pub adaptive_refresh: Option, @@ -130,6 +131,7 @@ impl SettingsUpdate { || self.overview_layout.is_some() || self.provider_metrics.is_some() || self.provider_hidden_usage_item_ids.is_some() + || self.preferred_currency_code.is_some() || self.codex_spark_usage_visible.is_some() || self.copilot_seat_credit_entitlement.is_some() || self.enabled_providers.is_some() @@ -200,6 +202,13 @@ impl SettingsUpdate { } fn apply_general_settings(self, settings: &mut Settings) -> Result { + if let Some(value) = self.preferred_currency_code.as_deref() { + let normalized = codexbar::currency::normalize_preferred_currency(value); + if !value.trim().eq_ignore_ascii_case("AUTO") && normalized == "AUTO" { + return Err(format!("Unsupported preferred currency: {value}")); + } + settings.preferred_currency_code = normalized; + } if let Some(v) = self.start_at_login { settings.set_start_at_login(v).map_err(|e| e.to_string())?; } @@ -572,6 +581,25 @@ pub async fn update_settings( mod tests { use super::*; + #[test] + fn preferred_currency_patch_accepts_supported_codes_and_rejects_unknown_codes() { + let mut settings = Settings::default(); + SettingsUpdate { + preferred_currency_code: Some("try".to_string()), + ..SettingsUpdate::default() + } + .apply_to(&mut settings) + .expect("TRY is supported"); + assert_eq!(settings.preferred_currency_code, "TRY"); + + let result = SettingsUpdate { + preferred_currency_code: Some("BTC".to_string()), + ..SettingsUpdate::default() + } + .apply_to(&mut settings); + assert!(matches!(result, Err(error) if error.contains("Unsupported preferred currency"))); + } + #[test] fn only_data_affecting_settings_refresh_providers() { assert!( diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 02f48672b6..28c6179b87 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -146,6 +146,7 @@ fn main() { tauri::Builder::default() .manage(Mutex::new(initial_state)) + .manage(commands::CurrencyRateCache::default()) .plugin(shortcut_bridge::plugin()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { @@ -159,6 +160,7 @@ fn main() { commands::get_bootstrap_state, commands::get_provider_catalog, commands::get_settings_snapshot, + commands::get_currency_rates, commands::list_agent_sessions, commands::focus_agent_session, commands::update_settings, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index aebb7c2115..5ddda446b1 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -564,11 +564,7 @@ fn provider_status_label( if preference == MetricPreference::MonthlyPlan && let Some(cost) = snapshot.cost.as_ref() { - let amount = if !cost.formatted_used.is_empty() { - cost.formatted_used.clone() - } else { - crate::commands::format_cost_amount(cost) - }; + let amount = crate::commands::format_cost_amount(cost); return ( snapshot.provider_id.clone(), format!("{} {}", snapshot.display_name, amount), diff --git a/apps/desktop-tauri/src/App.tsx b/apps/desktop-tauri/src/App.tsx index aa775058ad..7550c8136e 100644 --- a/apps/desktop-tauri/src/App.tsx +++ b/apps/desktop-tauri/src/App.tsx @@ -17,6 +17,7 @@ import { LocaleProvider } from "./i18n/LocaleProvider"; import type { BootstrapState, ThemePreference } from "./types/bridge"; import type { SurfaceSnapshot } from "./hooks/useSurfaceSnapshot"; import { useDeepSeekPricingStatus } from "./hooks/useDeepSeekPricingStatus"; +import { CurrencyProvider } from "./hooks/CurrencyProvider"; const Settings = lazy(() => import("./surfaces/Settings")); const PopOutPanel = lazy(() => import("./surfaces/PopOutPanel")); @@ -50,7 +51,9 @@ function initialSettingsTab(): string { export default function App() { return ( - + + + ); } diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 31634bd910..0b674e2f97 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -12,6 +12,7 @@ import type { SessionEquivalentForecastSnapshot, } from "../types/bridge"; import { useLocale } from "../hooks/useLocale"; +import { useCurrency } from "../hooks/CurrencyProvider"; import { providerAllowsPace } from "../lib/providerPace"; import { useFormattedResetTime, @@ -63,7 +64,6 @@ function formatSessionEquivalentEstimate( return `Estimated: ${display} ${unit} left`; } -const currencyFormatters = new Map(); const compactCountFormat0 = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 0, @@ -73,22 +73,6 @@ const compactCountFormat1 = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1, }); -function formatCurrency(amount: number, code: string): string { - try { - let formatter = currencyFormatters.get(code); - if (!formatter) { - formatter = new Intl.NumberFormat("en-US", { - style: "currency", - currency: code, - }); - currencyFormatters.set(code, formatter); - } - return formatter.format(amount); - } catch { - return `${code} ${amount.toFixed(2)}`; - } -} - function formatCompactCount(value: number | null): string { if (value == null || value <= 0) return "—"; return (value >= 1_000_000 ? compactCountFormat1 : compactCountFormat0).format( @@ -112,6 +96,7 @@ function LocalUsageBlock({ costHistory: DailyCostPoint[]; }) { const { t } = useLocale(); + const { format } = useCurrency(); const isCodex = providerId === "codex"; const isMuse = providerId === "muse"; const visibleHistory = costHistory.slice(-30); @@ -131,7 +116,7 @@ function LocalUsageBlock({ ? formatCompactCount(summary.latestTokens) : "—") : summary.todayCost != null - ? formatCurrency(summary.todayCost, "USD") + ? format(summary.todayCost, "USD") : "—"} @@ -140,7 +125,7 @@ function LocalUsageBlock({ {t("PanelThirtyDayCost")} {summary.thirtyDayCost != null - ? formatCurrency(summary.thirtyDayCost, "USD") + ? format(summary.thirtyDayCost, "USD") : "—"} @@ -166,7 +151,7 @@ function LocalUsageBlock({ height: `${point.value == null || maxCost <= 0 ? 1 : Math.max(4, Math.round((point.value / maxCost) * 64))}px`, opacity: point.value == null ? 0 : undefined, }} - title={point.value == null ? point.date : `${point.date}: ${formatCurrency(point.value, "USD")}`} + title={point.value == null ? point.date : `${point.date}: ${format(point.value, "USD")}`} /> ))} @@ -528,6 +513,15 @@ export default function MenuCardDetails({ onLayoutChange, }: MenuCardDetailsProps) { const { t } = useLocale(); + const { format, preferredCode } = useCurrency(); + const formatProviderCost = ( + amount: number | null | undefined, + currencyCode: string, + sourceSymbol?: string | null, + sourceFormatted?: string | null, + ) => preferredCode === "AUTO" && sourceFormatted + ? sourceFormatted + : format(amount, currencyCode, sourceSymbol); const paceEnabled = display.showPace !== false && providerAllowsPace(provider.providerId, provider.sourceLabel); @@ -626,49 +620,30 @@ export default function MenuCardDetails({ {provider.cost.balance != null && provider.cost.limit == null ? (
- {provider.cost.formattedBalance || - formatCurrency( - provider.cost.balance, - provider.cost.currencyCode, - )} + {formatProviderCost(provider.cost.balance, provider.cost.currencyCode, provider.cost.currencySymbol, provider.cost.formattedBalance)}
) : ( <>
{t("DetailCostUsed")}:{" "} - {provider.cost.formattedUsed || - formatCurrency( - provider.cost.used, - provider.cost.currencyCode, - )} + {formatProviderCost(provider.cost.used, provider.cost.currencyCode, provider.cost.currencySymbol, provider.cost.formattedUsed)} {provider.cost.limit != null && ( <> {" / "} - {provider.cost.formattedLimit || - formatCurrency( - provider.cost.limit, - provider.cost.currencyCode, - )} + {formatProviderCost(provider.cost.limit, provider.cost.currencyCode, provider.cost.currencySymbol, provider.cost.formattedLimit)} )}
{costStyle === "detailed" && provider.cost.balance != null && (
{t("DetailCostBalance")}:{" "} - {provider.cost.formattedBalance || - formatCurrency( - provider.cost.balance, - provider.cost.currencyCode, - )} + {formatProviderCost(provider.cost.balance, provider.cost.currencyCode, provider.cost.currencySymbol, provider.cost.formattedBalance)}
)} {costStyle === "detailed" && provider.cost.remaining != null && (
{t("DetailCostRemaining")}:{" "} - {formatCurrency( - provider.cost.remaining, - provider.cost.currencyCode, - )} + {format(provider.cost.remaining, provider.cost.currencyCode, provider.cost.currencySymbol)}
)} {costStyle === "detailed" && formattedCostReset && ( @@ -681,9 +656,7 @@ export default function MenuCardDetails({ {provider.providerId === "mistral" && provider.cost && (
{t("MistralMonthlySpend")}:{" "} - {provider.cost.currencySymbol - ? `${provider.cost.currencySymbol}${provider.cost.used.toFixed(2)}` - : provider.cost.formattedUsed} + {formatProviderCost(provider.cost.used, provider.cost.currencyCode, provider.cost.currencySymbol, provider.cost.formattedUsed)}
)} diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index e8d71af8fe..4a55f2faa5 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -10,6 +10,7 @@ import { import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useFormattedResetTime } from "../hooks/useFormattedResetTime"; +import { useCurrency } from "../hooks/CurrencyProvider"; import { useLocale } from "../hooks/useLocale"; import { useProviders } from "../hooks/useProviders"; import { @@ -109,11 +110,6 @@ function hasLocalCost(summary: ProviderLocalUsageSummary | null): summary is Pro return summary?.todayCost != null || summary?.thirtyDayCost != null; } -function formatUsd(value: number | null): string | null { - if (value == null || !Number.isFinite(value)) return null; - return `$${value.toFixed(2)}`; -} - function CostPill({ summary, scale, @@ -127,8 +123,9 @@ function CostPill({ thirtyDayLabel: string; estimateLabel: string; }) { - const today = formatUsd(summary.todayCost); - const thirtyDay = formatUsd(summary.thirtyDayCost); + const { format } = useCurrency(); + const today = summary.todayCost == null ? null : format(summary.todayCost, "USD"); + const thirtyDay = summary.thirtyDayCost == null ? null : format(summary.thirtyDayCost, "USD"); const iconSize = Math.round(10 * scale); const brand = getProviderIcon(summary.providerId).brandColor; const title = [ diff --git a/apps/desktop-tauri/src/hooks/CurrencyProvider.tsx b/apps/desktop-tauri/src/hooks/CurrencyProvider.tsx new file mode 100644 index 0000000000..c31cf90029 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/CurrencyProvider.tsx @@ -0,0 +1,72 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { getCurrencyRates, getSettingsSnapshot } from "../lib/tauri"; +import { FALLBACK_CURRENCY_RATES, formatDisplayCurrency, mergeValidCurrencyRates, normalizePreferredCurrency } from "../lib/currency"; +import type { SettingsSnapshot } from "../types/bridge"; + +interface CurrencyContextValue { + preferredCode: string; + rates: Record; + format: (amount: number | null | undefined, sourceCode: string, sourceSymbol?: string | null) => string; +} + +const CurrencyContext = createContext({ + preferredCode: "AUTO", + rates: FALLBACK_CURRENCY_RATES, + format: (amount, sourceCode, sourceSymbol) => formatDisplayCurrency(amount, sourceCode, "AUTO", FALLBACK_CURRENCY_RATES, sourceSymbol), +}); + +export function CurrencyProvider({ children }: { children: ReactNode }) { + const [preferredCode, setPreferredCode] = useState("AUTO"); + const [rates, setRates] = useState(FALLBACK_CURRENCY_RATES); + const requestId = useRef(0); + + const applySettings = useCallback((settings: SettingsSnapshot) => { + const selected = normalizePreferredCurrency(settings.preferredCurrencyCode); + setPreferredCode(selected); + if (selected === "AUTO") { + requestId.current += 1; + return; + } + const id = ++requestId.current; + void getCurrencyRates(selected) + .then((snapshot) => { + if (requestId.current === id) setRates(mergeValidCurrencyRates(snapshot.rates)); + }) + .catch(() => { + // Keep the offline fallback; exchange-rate availability never blocks app surfaces. + }); + }, []); + + useEffect(() => { + let active = true; + void getSettingsSnapshot().then((settings) => { if (active) applySettings(settings); }).catch(() => {}); + const onUpdated = (event: Event) => { + const settings = (event as CustomEvent).detail; + if (settings) applySettings(settings); + }; + window.addEventListener("codexbar:settings-updated", onUpdated); + let unlisten: (() => void) | undefined; + void listen("settings-changed", () => { + void getSettingsSnapshot().then((settings) => { if (active) applySettings(settings); }).catch(() => {}); + }).then((stop) => { if (active) unlisten = stop; else stop(); }).catch(() => {}); + return () => { + active = false; + requestId.current += 1; + window.removeEventListener("codexbar:settings-updated", onUpdated); + unlisten?.(); + }; + }, [applySettings]); + + const format = useCallback( + (amount: number | null | undefined, sourceCode: string, sourceSymbol?: string | null) => + formatDisplayCurrency(amount, sourceCode, preferredCode, rates, sourceSymbol), + [preferredCode, rates], + ); + + return {children}; +} + +export function useCurrency(): CurrencyContextValue { + return useContext(CurrencyContext); +} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 195620e3bf..8a10817b45 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -508,6 +508,8 @@ export const ALL_LOCALE_KEYS = [ "SectionUsageRendering", "SectionTime", "SectionLanguage", + "PreferredCurrencyLabel", + "PreferredCurrencyHelper", "SectionCredentialsSecurity", "SectionDebug", "SectionApiKeys", diff --git a/apps/desktop-tauri/src/lib/currency.test.ts b/apps/desktop-tauri/src/lib/currency.test.ts new file mode 100644 index 0000000000..96a05d5879 --- /dev/null +++ b/apps/desktop-tauri/src/lib/currency.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + FALLBACK_CURRENCY_RATES, + convertCurrencyAmount, + formatDisplayCurrency, + mergeValidCurrencyRates, + normalizePreferredCurrency, + sumDisplayCurrencyAmounts, +} from "./currency"; + +describe("preferred currency display", () => { + it("normalizes supported preferences and falls back safely for unknown codes", () => { + expect(normalizePreferredCurrency(undefined)).toBe("AUTO"); + expect(normalizePreferredCurrency(" try ")).toBe("TRY"); + expect(normalizePreferredCurrency("BTC")).toBe("AUTO"); + }); + + it("converts both currencies through the USD pivot and rounds for display", () => { + expect(convertCurrencyAmount(10, "USD", "TRY", FALLBACK_CURRENCY_RATES)).toBe(485); + expect(convertCurrencyAmount(10, "GBP", "TRY", FALLBACK_CURRENCY_RATES)).toBeCloseTo(613.92405, 4); + const display = formatDisplayCurrency(10, "USD", "TRY", FALLBACK_CURRENCY_RATES); + expect(display).not.toContain("10.00"); + expect(display).toMatch(/485/); + }); + + it("keeps AUTO, credits, unknown units, and missing-rate values in source units", () => { + expect(formatDisplayCurrency(4.25, "USD", "AUTO", FALLBACK_CURRENCY_RATES)).toMatch(/4\.25/); + expect(formatDisplayCurrency(4.25, "Credits", "TRY", FALLBACK_CURRENCY_RATES)).toBe("4.25 Credits"); + expect(formatDisplayCurrency(10, "USD", "TRY", {} , "$" )).toBe("$10.00"); + expect(convertCurrencyAmount(8, "Quota", "TRY", FALLBACK_CURRENCY_RATES)).toBeNull(); + }); + + it("rejects malformed exchange rates and preserves offline fallbacks", () => { + const rates = mergeValidCurrencyRates({ USD: 1, TRY: Number.NaN, EUR: -2, BTC: 90 }); + expect(rates.TRY).toBe(48.5); + expect(rates.EUR).toBe(0.92); + expect(rates.BTC).toBeUndefined(); + }); + + it("sums only converted overview rows and reports incomplete coverage", () => { + const result = sumDisplayCurrencyAmounts([ + { amount: 10, currency: "USD" }, + { amount: 10, currency: "EUR" }, + { amount: 5, currency: "Credits" }, + ], "TRY", FALLBACK_CURRENCY_RATES); + expect(result.included).toBe(2); + expect(result.considered).toBe(3); + expect(result.total).toBeCloseTo(10 * 48.5 + (10 / 0.92) * 48.5, 8); + }); +}); diff --git a/apps/desktop-tauri/src/lib/currency.ts b/apps/desktop-tauri/src/lib/currency.ts new file mode 100644 index 0000000000..fb83245eed --- /dev/null +++ b/apps/desktop-tauri/src/lib/currency.ts @@ -0,0 +1,119 @@ +export const SUPPORTED_CURRENCIES = [ + "USD", "GBP", "EUR", "CZK", "CNY", "JPY", "KRW", "CAD", "AUD", "HKD", "TWD", "SGD", + "INR", "CHF", "AED", "TRY", +] as const; + +export const FALLBACK_CURRENCY_RATES: Record = { + USD: 1, + GBP: 0.79, + EUR: 0.92, + CZK: 21, + CNY: 7.27, + JPY: 154, + KRW: 1428.9, + CAD: 1.38, + AUD: 1.55, + HKD: 7.8, + TWD: 32.3, + SGD: 1.34, + INR: 84.5, + CHF: 0.8, + AED: 3.6725, + TRY: 48.5, +}; + +export function normalizePreferredCurrency(value: string | null | undefined): string { + const code = value?.trim().toUpperCase() || "AUTO"; + return code === "AUTO" || SUPPORTED_CURRENCIES.includes(code as (typeof SUPPORTED_CURRENCIES)[number]) + ? code + : "AUTO"; +} + +export function convertCurrencyAmount( + amount: number, + sourceCode: string, + targetCode: string, + rates: Record, +): number | null { + if (!Number.isFinite(amount)) return null; + const source = sourceCode.trim().toUpperCase(); + const target = targetCode.trim().toUpperCase(); + if (!SUPPORTED_CURRENCIES.includes(source as (typeof SUPPORTED_CURRENCIES)[number]) || + !SUPPORTED_CURRENCIES.includes(target as (typeof SUPPORTED_CURRENCIES)[number])) return null; + if (source === target) return amount; + const sourceRate = source === "USD" ? 1 : rates[source]; + const targetRate = target === "USD" ? 1 : rates[target]; + if (!Number.isFinite(sourceRate) || sourceRate <= 0 || !Number.isFinite(targetRate) || targetRate <= 0) return null; + const result = (amount / sourceRate) * targetRate; + return Number.isFinite(result) ? result : null; +} + +function formatOriginal(amount: number, code: string, symbol?: string | null): string { + if (symbol) return `${symbol}${amount.toFixed(2)}`; + if (!/^[A-Z]{3}$/.test(code)) return `${amount.toFixed(2)} ${code}`; + try { + return new Intl.NumberFormat("en-US", { style: "currency", currency: code }).format(amount); + } catch { + return `${amount.toFixed(2)} ${code}`; + } +} + +export function formatDisplayCurrency( + amount: number | null | undefined, + sourceCode: string, + preferredCode: string, + rates: Record, + sourceSymbol?: string | null, +): string { + if (amount == null || !Number.isFinite(amount)) return "—"; + const trimmedSource = sourceCode.trim(); + const source = trimmedSource.toUpperCase(); + const sourceLabel = /^[A-Za-z]{3}$/.test(trimmedSource) ? source : trimmedSource; + const preferred = normalizePreferredCurrency(preferredCode); + if (preferred === "AUTO") return formatOriginal(amount, sourceLabel, sourceSymbol); + const converted = convertCurrencyAmount(amount, source, preferred, rates); + if (converted == null) return formatOriginal(amount, sourceLabel, sourceSymbol); + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: preferred, + maximumFractionDigits: 2, + }).format(converted); + } catch { + return `${converted.toFixed(2)} ${preferred}`; + } +} + +export function mergeValidCurrencyRates(input: Record): Record { + const rates = { ...FALLBACK_CURRENCY_RATES }; + for (const code of SUPPORTED_CURRENCIES) { + const value = input[code]; + if (Number.isFinite(value) && value > 0 && (code !== "USD" || Math.abs(value - 1) <= Number.EPSILON)) { + rates[code] = value; + } + } + return rates; +} + +export function sumDisplayCurrencyAmounts( + rows: Array<{ amount: number | null | undefined; currency: string }>, + preferredCode: string, + rates: Record, +): { total: number | null; included: number; considered: number } { + const target = normalizePreferredCurrency(preferredCode); + let total = 0; + let included = 0; + for (const row of rows) { + if (row.amount == null || !Number.isFinite(row.amount)) continue; + let amount: number | null; + if (target === "AUTO") { + amount = row.currency.trim().toUpperCase() === "USD" ? row.amount : null; + } else { + amount = convertCurrencyAmount(row.amount, row.currency || "USD", target, rates); + } + if (amount == null) continue; + total += amount; + included += 1; + } + return { total: included > 0 && Number.isFinite(total) ? total : null, included, considered: rows.length }; +} diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 6ccd63c0de..18b7ad4302 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -45,6 +45,7 @@ import type { CodexAccountsStateBridge, CodexSwitchResult, DeepSeekPricingStatus, + CurrencyRatesSnapshot, } from "../types/bridge"; export const claudeAccountsList = () => invoke("claude_accounts_list"); @@ -87,6 +88,10 @@ export function getSettingsSnapshot(): Promise { return invoke("get_settings_snapshot"); } +export function getCurrencyRates(preferredCurrencyCode: string): Promise { + return invoke("get_currency_rates", { preferredCurrencyCode }); +} + export function updateSettings( patch: SettingsUpdate, ): Promise { diff --git a/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts b/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts index bf693ce7b8..148cf95b34 100644 --- a/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { formatUsageSpendReportingDay, @@ -104,4 +104,41 @@ describe("usage spend sharing", () => { expect(Object.keys(row).filter((key) => /email|org|token|account/i.test(key))).toEqual([]); expect(unsafeKeys).toEqual(["providerId", "includedInOverview"]); }); + + it("uses the supplied display formatter and currency resolver for PNG cells", () => { + const canvas = document.createElement("canvas"); + const context = { + scale: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(), fillText: vi.fn(), + beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), stroke: vi.fn(), + measureText: vi.fn(() => ({ width: 1 })), + } as unknown as CanvasRenderingContext2D; + vi.spyOn(canvas, "getContext").mockReturnValue(context); + vi.spyOn(canvas, "toDataURL").mockReturnValue("data:image/png;base64,test"); + const createElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tagName, options) => + tagName === "canvas" ? canvas : createElement(tagName, options), + ); + const formatMetric = vi.fn(() => "₺32.00 · 10 tokens"); + const displayCurrency = vi.fn(() => "TRY"); + const summary: UsageSpendSummary = { + contract: {} as SpendContract, + reportingDay: "2026-09-19", + dashboardTimezone: "UTC", + rows: [{ + providerId: "codex", displayName: "Codex", sevenDay: 1, thirtyDay: 2, + currency: "USD", source: "local", includedInOverview: true, + }], + }; + + try { + renderUsageSpendSharePng(summary, "Usage & Spend", { formatMetric, displayCurrency }); + expect(formatMetric).toHaveBeenCalledWith(1, undefined, "USD", "tokens"); + expect(formatMetric).toHaveBeenCalledWith(2, undefined, "USD", "tokens"); + expect(displayCurrency).toHaveBeenCalledWith("USD"); + expect(context.fillText).toHaveBeenCalledWith("₺32.00 · 10 tokens", expect.any(Number), expect.any(Number)); + expect(context.fillText).toHaveBeenCalledWith("TRY", expect.any(Number), expect.any(Number)); + } finally { + vi.restoreAllMocks(); + } + }); }); diff --git a/apps/desktop-tauri/src/lib/usageSpendSharing.ts b/apps/desktop-tauri/src/lib/usageSpendSharing.ts index 2246e98505..3da6473f4a 100644 --- a/apps/desktop-tauri/src/lib/usageSpendSharing.ts +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.ts @@ -139,6 +139,11 @@ export function formatSpendMetric( return parts.length > 0 ? parts.join(" · ") : "—"; } +export interface UsageSpendSharePresentation { + formatMetric?: (cost: number | null | undefined, tokens: number | null | undefined, sourceCurrency: string, tokenLabel: string) => string; + displayCurrency?: (sourceCurrency: string) => string; +} + /** * Render the sanitized share-card PNG. * @@ -148,7 +153,11 @@ export function formatSpendMetric( * states the guarantee. Keep it that way — do not add account fields to the * drawn cells or the footer. */ -export function renderUsageSpendSharePng(summary: UsageSpendSummary, title: string): string { +export function renderUsageSpendSharePng( + summary: UsageSpendSummary, + title: string, + presentation: UsageSpendSharePresentation = {}, +): string { const rows = summary.rows; const pad = 24; const rowH = 28; @@ -202,9 +211,11 @@ export function renderUsageSpendSharePng(summary: UsageSpendSummary, title: stri const y = y0 + (index + 1) * rowH; const cells = [ row.displayName, - formatSpendMetric(row.sevenDay, row.sevenDayTokens, row.currency, "tokens"), - formatSpendMetric(row.thirtyDay, row.thirtyDayTokens, row.currency, "tokens"), - row.currency || "USD", + presentation.formatMetric?.(row.sevenDay, row.sevenDayTokens, row.currency || "USD", "tokens") + ?? formatSpendMetric(row.sevenDay, row.sevenDayTokens, row.currency, "tokens"), + presentation.formatMetric?.(row.thirtyDay, row.thirtyDayTokens, row.currency || "USD", "tokens") + ?? formatSpendMetric(row.thirtyDay, row.thirtyDayTokens, row.currency, "tokens"), + presentation.displayCurrency?.(row.currency || "USD") ?? (row.currency || "USD"), row.source, ]; let cellX = pad; @@ -250,10 +261,11 @@ export function shareUsageSpendPng( summary: UsageSpendSummary | null, title: string, filename: string, + presentation: UsageSpendSharePresentation = {}, ): string | null { if (!summary) return "UsageSpendShareEmpty"; try { - const dataUrl = renderUsageSpendSharePng(summary, title); + const dataUrl = renderUsageSpendSharePng(summary, title, presentation); if (!dataUrl) return "UsageSpendShareFailed"; downloadPng(dataUrl, filename); return null; diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index ccc2e8fe2f..b068920c2b 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -2,6 +2,8 @@ import { Fragment, useEffect, useState, type CSSProperties } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import type { BootstrapState, ProviderUsageSnapshot, UsageSpendSummary } from "../types/bridge"; import type { LocaleKey } from "../i18n/keys"; +import { useCurrency } from "../hooks/CurrencyProvider"; +import { normalizePreferredCurrency, sumDisplayCurrencyAmounts } from "../lib/currency"; import { beginFlyoutGesture, getUsageSpendSummary, @@ -337,6 +339,7 @@ function TrayResizeHandles() { } function OverviewSpendSummary({ providerIds, t }: { providerIds: string[]; t: (key: LocaleKey) => string }) { + const { preferredCode, rates, format } = useCurrency(); const [summary, setSummary] = useState(null); const [shareError, setShareError] = useState(null); @@ -364,21 +367,25 @@ function OverviewSpendSummary({ providerIds, t }: { providerIds: string[]; t: (k }; const rows = overviewSummary.rows; - const summable = rows.filter((row) => (row.currency || "USD") === "USD"); - const known = summable.filter((row) => row.thirtyDay != null && Number.isFinite(row.thirtyDay)); - if (known.length === 0) return null; - const total = known.reduce((sum, row) => sum + (row.thirtyDay ?? 0), 0); - const partial = known.length < rows.length; - const formatter = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 2 }); + const target = normalizePreferredCurrency(preferredCode); + const aggregate = sumDisplayCurrencyAmounts( + rows.map((row) => ({ amount: row.thirtyDay, currency: row.currency || "USD" })), + target, + rates, + ); + const partial = aggregate.included < aggregate.considered; + const displayedTotal = aggregate.total == null + ? "—" + : `${partial ? "~" : ""}${format(aggregate.total, target === "AUTO" ? "USD" : target)}`; return (
{t("OverviewSpendTitle")} - {partial ? "~" : ""}{formatter.format(total)} + {displayedTotal}
- {known.length} of {rows.length} {t("OverviewSpendProviderCoverage")} · {t("OverviewSpendEstimate")} + {aggregate.included} of {aggregate.considered} {t("OverviewSpendProviderCoverage")} · {t("OverviewSpendEstimate")}