diff --git a/CHANGELOG.md b/CHANGELOG.md index 068c1176a7c..a0751c1301d 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Windows] Unreleased +- **Claude**: show the plan renewal or expiry date from claude.ai billing under the quota rows when your cookie session supplies it, and make saved limit-reset credits, Extra usage (including a prepaid balance), and plan dates independently selectable under Visible usage items. Existing settings keep all three visible; Admin API spend is unaffected (upstream #4324, #4484). - Devin: use Devin's official transparent hexagon mark for the provider icon in Settings and the CLI dashboard, instead of the settings-gear badge and the letter fallback (upstream #4458). - Kimi (China): use the current Kimi Desktop session from its Local Storage (`%APPDATA%\kimi-desktop`) for web usage and monthly membership enrichment, and check that session again before each request, so a signed-out, replaced, or expired Desktop token is never sent or recovered from an older record (upstream #4416, #3536). - Codex CLI: add opt-in `cost --identified-only` request subtotals across local and SSH (`--remote`) histories, deduplicating strong request identities and keeping partial results without estimating a combined price, plus `--group-by project` reports that show the partial-history notice even when no projects are listed (upstream #4447, #4472). diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 990c95ed2d8..178d4b8bd8a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -271,6 +271,11 @@ pub struct SubscriptionMetadataSnapshot { pub starts_at: Option, pub expires_at: Option, pub renews_at: Option, + /// Date-only values are UTC midnight and must be formatted in UTC. + #[serde(default)] + pub renews_at_is_date_only: bool, + #[serde(default)] + pub expires_at_is_date_only: bool, } /// Display-only provider inventory. Redemption identifiers never cross the @@ -692,6 +697,8 @@ impl ProviderUsageSnapshot { starts_at: subscription.starts_at.map(|date| date.to_rfc3339()), expires_at: subscription.expires_at.map(|date| date.to_rfc3339()), renews_at: subscription.renews_at.map(|date| date.to_rfc3339()), + renews_at_is_date_only: subscription.renews_at_is_date_only, + expires_at_is_date_only: subscription.expires_at_is_date_only, } }), source_label: result.source_label.clone(), diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 7ff62bb6529..808511afed0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -49,6 +49,8 @@ pub struct ProviderDetail { pub hidden_usage_item_ids: Vec, pub inventory: Vec, pub display_details: Vec, + /// Plan renewal / expiry dates for the Settings usage preview. + pub subscription: Option, // Cost / pace. pub cost: Option, @@ -147,6 +149,7 @@ pub(crate) fn build_provider_detail( hidden_usage_item_ids: settings.hidden_usage_item_ids(id), inventory: Vec::new(), display_details: Vec::new(), + subscription: None, cost: None, pace: None, open_ai_api_usage: None, @@ -223,6 +226,7 @@ pub fn get_provider_detail( detail.extra_rate_windows = snapshot.extra_rate_windows.clone(); detail.inventory = snapshot.inventory.clone(); detail.display_details = snapshot.display_details.clone(); + detail.subscription = snapshot.subscription.clone(); detail.cost = snapshot.cost.clone(); detail.pace = snapshot.pace.clone(); detail.open_ai_api_usage = snapshot.open_ai_api_usage.clone(); diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index b7c529d16f7..d1011d0fba7 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -1549,6 +1549,119 @@ fn usage_item_descriptors_skip_card_hidden_windows() { ); } +fn claude_snapshot_with_sections(source_label: &str) -> ProviderUsageSnapshot { + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let result = ProviderFetchResult { + usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), + cost: Some(codexbar::core::CostSnapshot::new(0.0, "USD", "Extra usage").with_balance(5.0)), + wayfinder_usage: None, + open_ai_api_usage: None, + inventory: Vec::new(), + reset_credits: None, + display_details: Vec::new(), + source_label: source_label.to_string(), + has_successful_claude_cli_quota: false, + pace_authoritative: true, + account_identity: None, + last_good_owner: None, + }; + let mut snapshot = ProviderUsageSnapshot::from_fetch_result( + ProviderId::Claude, + &metadata, + &result, + None, + None, + ); + snapshot.inventory = vec![super::bridge::ProviderInventoryItemSnapshot { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 2, + next_expires_at: None, + }]; + snapshot.subscription = Some(super::bridge::SubscriptionMetadataSnapshot { + starts_at: None, + expires_at: None, + renews_at: Some("2026-12-01T00:00:00+00:00".to_string()), + renews_at_is_date_only: true, + expires_at_is_date_only: false, + }); + snapshot +} + +#[test] +fn usage_item_descriptors_offer_independent_claude_sections() { + let snapshot = claude_snapshot_with_sections("web"); + let items = + super::usage_item_descriptors(Some(&snapshot), &Settings::default(), ProviderId::Claude); + let sections: Vec<(&str, &str, bool, bool)> = items + .iter() + .filter(|item| item.id.starts_with("section:")) + .map(|item| { + ( + item.id.as_str(), + item.title.as_str(), + item.available, + item.requires_cost_summary, + ) + }) + .collect(); + assert_eq!( + sections, + [ + ("section:subscription", "Plan renewal / expiry", true, false), + ( + "section:claude-reset-credits", + "Limit reset credits", + true, + false + ), + ("section:claude-extra-usage", "Extra usage", true, true), + ] + ); + + // Admin API spend is not Extra usage and has no Extra usage choice. + let admin = claude_snapshot_with_sections("admin-api"); + let items = + super::usage_item_descriptors(Some(&admin), &Settings::default(), ProviderId::Claude); + assert!( + !items + .iter() + .any(|item| item.id == "section:claude-extra-usage") + ); + + // The same sections are never offered for another provider. + let items = + super::usage_item_descriptors(Some(&snapshot), &Settings::default(), ProviderId::Codex); + assert!(!items.iter().any(|item| item.id.starts_with("section:"))); +} + +#[test] +fn usage_item_descriptors_keep_hidden_claude_sections_restorable() { + let mut settings = Settings::default(); + settings.set_hidden_usage_item_ids( + ProviderId::Claude, + vec![ + "section:claude-reset-credits".to_string(), + "section:unknown".to_string(), + ], + ); + let items = super::usage_item_descriptors(None, &settings, ProviderId::Claude); + let ids: Vec<(&str, &str, bool)> = items + .iter() + .map(|item| (item.id.as_str(), item.title.as_str(), item.available)) + .collect(); + assert_eq!( + ids, + [( + "section:claude-reset-credits", + "Limit reset credits (unavailable)", + false + )] + ); + // Section IDs never become "metric:section:..." placeholders. + assert!(!items.iter().any(|item| item.id.starts_with("metric:"))); +} + #[test] fn pace_stage_serializes_to_snake_case_string() { use codexbar::core::PaceStage; diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs index a037e5d733d..f676e235683 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs @@ -7,9 +7,9 @@ use codexbar::settings::Language; use serde::{Deserialize, Serialize}; /// Presentation descriptor for one quota metric or provider-emitted extra -/// usage row. This intentionally excludes inventory and transient detail -/// sections: Windows only exposes the metric rows already present in the -/// provider snapshot for this visibility lane. +/// usage row, a provider detail section, or a provider-owned `section:` card +/// block (Claude reset credits, Extra usage, plan dates). Other inventory +/// rows have no visibility choice. /// /// Descriptor contract: every row the pane displays is either persisted in the /// provider's hidden-usage-item list or emitted by the current provider @@ -22,12 +22,75 @@ pub struct ProviderUsageItemSnapshot { pub id: String, pub title: String, pub available: bool, + /// The row is also suppressed while Cost summary display is Hidden, so + /// the pane explains why checking it may show nothing. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub requires_cost_summary: bool, } pub(crate) fn usage_item_id(raw_id: &str) -> String { format!("{}{}", codexbar::settings::USAGE_ITEM_METRIC_PREFIX, raw_id) } +/// Provider-specific by design (upstream 0.75.0 #4484): Claude's saved reset +/// credits, Extra usage / prepaid balance and plan dates are card sections +/// with their own visibility choice. Returns the title and whether the row is +/// also gated by Cost summary display. +fn claude_section_title(id: &str, lang: Language) -> Option<(String, bool)> { + use codexbar::settings::{ + CLAUDE_EXTRA_USAGE_USAGE_ITEM_ID, CLAUDE_RESET_CREDITS_USAGE_ITEM_ID, + SUBSCRIPTION_USAGE_ITEM_ID, + }; + let (key, requires_cost_summary) = match id { + SUBSCRIPTION_USAGE_ITEM_ID => (LocaleKey::UsageItemPlanRenewalExpiry, false), + CLAUDE_RESET_CREDITS_USAGE_ITEM_ID => (LocaleKey::ProviderLabelLimitResetCredits, false), + CLAUDE_EXTRA_USAGE_USAGE_ITEM_ID => (LocaleKey::ExtraUsage, true), + _ => return None, + }; + Some((locale::get_text(lang, key), requires_cost_summary)) +} + +/// The Claude section IDs the current snapshot reports, in card order: plan +/// dates sit under the quota rows, then saved resets, then Extra usage. +fn claude_reported_sections(snapshot: &ProviderUsageSnapshot) -> Vec<&'static str> { + use codexbar::settings::{ + CLAUDE_EXTRA_USAGE_USAGE_ITEM_ID, CLAUDE_RESET_CREDITS_USAGE_ITEM_ID, + SUBSCRIPTION_USAGE_ITEM_ID, + }; + let mut ids = Vec::new(); + if snapshot + .subscription + .as_ref() + .is_some_and(|s| s.renews_at.is_some() || s.expires_at.is_some()) + { + ids.push(SUBSCRIPTION_USAGE_ITEM_ID); + } + if snapshot + .inventory + .iter() + .any(|item| item.id == CLAUDE_RESET_CREDITS_INVENTORY_ID) + { + ids.push(CLAUDE_RESET_CREDITS_USAGE_ITEM_ID); + } + if is_claude_extra_usage_cost(snapshot) { + ids.push(CLAUDE_EXTRA_USAGE_USAGE_ITEM_ID); + } + ids +} + +/// Inventory ID the Claude provider assigns to saved limit resets. +pub(crate) const CLAUDE_RESET_CREDITS_INVENTORY_ID: &str = "reset-credits"; + +/// Claude's Extra usage / prepaid balance block. Admin API spend is a +/// separate cost and keeps showing regardless of this choice. +pub(crate) fn is_claude_extra_usage_cost(snapshot: &ProviderUsageSnapshot) -> bool { + snapshot + .cost + .as_ref() + .is_some_and(|cost| !cost.always_visible) + && snapshot.source_label != "admin-api" +} + fn redacted_usage_item_title(title: &str, settings: &Settings) -> String { PersonalInfoRedactor::hide_emails_in_text(Some(title), settings.hide_personal_info) .unwrap_or_default() @@ -109,6 +172,7 @@ pub(crate) fn usage_item_descriptors( id, title: redacted_usage_item_title(&detail.title, settings), available: true, + requires_cost_summary: false, }); } } @@ -129,6 +193,7 @@ pub(crate) fn usage_item_descriptors( settings, ), available: false, + requires_cost_summary: false, }); } } @@ -167,6 +232,7 @@ pub(crate) fn usage_item_descriptors( id, title: item_title, available, + requires_cost_summary: false, }); } }; @@ -203,8 +269,10 @@ pub(crate) fn usage_item_descriptors( } } - for id in hidden { - if id.starts_with(codexbar::settings::USAGE_ITEM_DETAIL_SECTION_PREFIX) { + for id in &hidden { + if id.starts_with(codexbar::settings::USAGE_ITEM_DETAIL_SECTION_PREFIX) + || id.starts_with(codexbar::settings::USAGE_ITEM_SECTION_PREFIX) + { continue; } let raw_id = id @@ -213,6 +281,35 @@ pub(crate) fn usage_item_descriptors( push(raw_id, "", false); } + if provider_id == ProviderId::Claude { + let reported = snapshot.map(claude_reported_sections).unwrap_or_default(); + let saved = hidden.iter().map(String::as_str).filter(|id| { + id.starts_with(codexbar::settings::USAGE_ITEM_SECTION_PREFIX) && !reported.contains(id) + }); + let rows = reported + .iter() + .map(|id| (*id, true)) + .chain(saved.map(|id| (id, false))) + .collect::>(); + for (id, available) in rows { + let Some((title, requires_cost_summary)) = claude_section_title(id, lang) else { + continue; + }; + if seen.insert(id.to_string()) { + items.push(ProviderUsageItemSnapshot { + id: id.to_string(), + title: if available { + title + } else { + locale::format_locale(lang, LocaleKey::UsageItemUnavailableTitle, &[&title]) + }, + available, + requires_cost_summary, + }); + } + } + } + items } diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 4fa122cbbe0..d6de38c645c 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -1405,6 +1405,93 @@ describe("MenuCard", () => { expect(container.querySelector(".card-extra")).toBeNull(); }); + describe("Claude reset credits, Extra usage and plan dates (upstream #4484)", () => { + function claude(hidden: string[] = []): ProviderUsageSnapshot { + const snapshot = provider(null, 20); + snapshot.sourceLabel = "web"; + snapshot.hiddenUsageItemIds = hidden; + snapshot.inventory = [ + { id: "reset-credits", title: "Limit Reset Credits", availableCount: 2, nextExpiresAt: null }, + ]; + snapshot.cost = { + used: 0, + limit: null, + remaining: null, + currencyCode: "USD", + period: "Extra usage", + resetsAt: null, + formattedUsed: "$0.00", + formattedLimit: null, + balance: 5, + formattedBalance: "$5.00", + }; + snapshot.subscription = { + startsAt: null, + expiresAt: null, + renewsAt: "2026-12-01T00:00:00+00:00", + renewsAtIsDateOnly: true, + }; + return snapshot; + } + + beforeEach(() => { + tauriMocks.getLocaleStrings.mockResolvedValue( + buildBundle({ + ExtraUsage: "Extra usage", + DetailCostBalance: "Balance", + InventoryAvailableCount: "{} available", + IntlLocale: "en-US", + PlanRenewsOn: "Renews: {}", + PlanExpiresOn: "Plan expires: {}", + }), + ); + }); + + it("shows the plan date under the quota rows, before saved resets and Extra usage", async () => { + const { container } = renderCard(claude()); + const note = await screen.findByText("Renews: Dec 1, 2026"); + const content = container.querySelector(".menu-card__content"); + const order = [...(content?.children ?? [])].map((el) => el.className); + expect(order.indexOf("menu-card__metrics")).toBeLessThan(order.indexOf(note.className)); + expect(order.indexOf(note.className)).toBeLessThan(order.indexOf("card-kv")); + expect(screen.getByText("2 available")).toBeInTheDocument(); + expect(container.querySelector(".card-extra")).not.toBeNull(); + }); + + it("hides each section independently", async () => { + const { container, unmount } = renderCard(claude(["section:subscription"])); + expect(await screen.findByText("2 available")).toBeInTheDocument(); + expect(screen.queryByText(/Renews:/)).not.toBeInTheDocument(); + expect(container.querySelector(".card-extra")).not.toBeNull(); + unmount(); + + const resets = renderCard(claude(["section:claude-reset-credits"])); + expect(await screen.findByText("Renews: Dec 1, 2026")).toBeInTheDocument(); + expect(screen.queryByText("2 available")).not.toBeInTheDocument(); + expect(resets.container.querySelector(".card-extra")).not.toBeNull(); + resets.unmount(); + + const extra = renderCard(claude(["section:claude-extra-usage"])); + expect(await screen.findByText("Renews: Dec 1, 2026")).toBeInTheDocument(); + expect(screen.getByText("2 available")).toBeInTheDocument(); + expect(extra.container.querySelector(".card-extra")).toBeNull(); + }); + + it("never hides Admin API spend through the Extra usage choice", async () => { + const snapshot = claude(["section:claude-extra-usage"]); + snapshot.sourceLabel = "admin-api"; + const { container } = renderCard(snapshot); + await screen.findByText("Renews: Dec 1, 2026"); + expect(container.querySelector(".card-extra")).not.toBeNull(); + }); + + it("keeps the plan date off the compact Overview when quota bars are present", async () => { + const { container } = renderCard(claude(), { compactOverview: true }); + await waitFor(() => expect(container.querySelector(".menu-metric__title")).not.toBeNull()); + expect(screen.queryByText(/Renews:/)).not.toBeInTheDocument(); + }); + }); + describe("LithosAI shows its balance twice, as on the Mac", () => { function lithos(balance: number | null, shown: string): ProviderUsageSnapshot { const snapshot = provider(null, 0, { resetDescription: `Balance: ${shown}` }); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index e7fe51834a9..5ec8f1a8079 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -10,7 +10,11 @@ import { useLocale } from "../hooks/useLocale"; import { providerSupportsChartData } from "../lib/providerCharts"; import { providerAllowsPace } from "../lib/providerPace"; import { hideOpenAiApiProjectId } from "../lib/openAiApiIdentity"; -import { isUsageItemVisible } from "../lib/usageItemVisibility"; +import { + claudePlanDateNote, + isUsageItemVisible, + projectClaudeSections, +} from "../lib/usageItemVisibility"; import { localizeWindowLabel } from "../lib/windowLabels"; import { inlineDashboardModel } from "../lib/inlineDashboard"; import { useMonthlyLimitBlockNow } from "../hooks/useMonthlyLimitBlockNow"; @@ -136,7 +140,7 @@ function cardMetrics( * forecasts, burndown and pricing details live in Settings > Providers. */ export default function MenuCard({ - provider, + provider: reportedProvider, display, isRefreshing = false, accentColor, @@ -157,6 +161,7 @@ export default function MenuCard({ costTrackingEnabled = true, } = display; const { t, language } = useLocale(); + const provider = projectClaudeSections(reportedProvider.providerId, reportedProvider); const [chartData, setChartData] = useState(null); // Another provider or account must not show the previous card's chart. @@ -210,6 +215,7 @@ export default function MenuCard({ metrics, costSummaryDisplayStyle, compactOverview, + claudePlanDateNote(provider.providerId, provider.subscription, t), ); const dashboard = !compactOverview && presence.localUsage @@ -281,6 +287,7 @@ export default function MenuCard({ ))} )} + {presence.planDateNote &&

{presence.planDateNote}

} {showDetailSections && ( } {showCost && provider.cost && ( <> - {(presence.hasMetrics || showDetailSections || dashboard) && ( + {(presence.hasMetrics || + presence.planDateNote || + showDetailSections || + dashboard) && (
)} 0; @@ -64,9 +70,15 @@ export function describeCard( const localUsage = hasError ? null : chartData?.localUsage ?? null; const wayfinderUsage = !hasError && provider.providerId === "wayfinder" ? provider.wayfinderUsage ?? null : null; + const planDateNote = !hasError && planNote && (!compactOverview || !hasMetrics) ? planNote : null; const hasDetails = compactOverview - ? hasMetrics || showsDetailSections || !!wayfinderUsage - : hasMetrics || hasDetailSections || hasCost || !!localUsage || !!wayfinderUsage; + ? hasMetrics || showsDetailSections || !!wayfinderUsage || !!planDateNote + : hasMetrics || + hasDetailSections || + hasCost || + !!localUsage || + !!wayfinderUsage || + !!planDateNote; return { hasMetrics, hasDetailSections, @@ -75,5 +87,6 @@ export function describeCard( localUsage, wayfinderUsage, hasDetails, + planDateNote, }; } diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 90b94d54c84..57c0f2296ab 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -1687,6 +1687,10 @@ export const ALL_LOCALE_KEYS = [ "AntigravityProfileHomesRemove", "AntigravityProfileHomesEmpty", "AntigravityProfileHomesPickFailed", + "UsageItemPlanRenewalExpiry", + "UsageItemHiddenByCostSummary", + "PlanRenewsOn", + "PlanExpiresOn", ] as const; export type LocaleKey = (typeof ALL_LOCALE_KEYS)[number]; diff --git a/apps/desktop-tauri/src/lib/usageItemVisibility.test.ts b/apps/desktop-tauri/src/lib/usageItemVisibility.test.ts new file mode 100644 index 00000000000..63de60313f8 --- /dev/null +++ b/apps/desktop-tauri/src/lib/usageItemVisibility.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { fromFtl } from "../test/localeHarness"; +import { + CLAUDE_EXTRA_USAGE_ITEM_ID, + CLAUDE_RESET_CREDITS_ITEM_ID, + SUBSCRIPTION_ITEM_ID, + claudePlanDateNote, + projectClaudeSections, +} from "./usageItemVisibility"; + +const en = fromFtl("en-US.ftl"); + +function sections(hidden: string[]) { + return { + inventory: [ + { id: "reset-credits", title: "Limit Reset Credits", availableCount: 1, nextExpiresAt: null }, + { id: "other", title: "Other", availableCount: 3, nextExpiresAt: null }, + ], + cost: { + used: 0, + limit: null, + remaining: null, + currencyCode: "USD", + period: "Extra usage", + resetsAt: null, + formattedUsed: "$0.00", + formattedLimit: null, + balance: 5, + formattedBalance: null, + }, + subscription: { + startsAt: null, + expiresAt: null, + renewsAt: "2026-12-01T00:00:00+00:00", + renewsAtIsDateOnly: true, + }, + sourceLabel: "web", + hiddenUsageItemIds: hidden, + }; +} + +describe("projectClaudeSections", () => { + it("drops only the hidden Claude sections", () => { + const all = projectClaudeSections("claude", sections([ + CLAUDE_RESET_CREDITS_ITEM_ID, + CLAUDE_EXTRA_USAGE_ITEM_ID, + SUBSCRIPTION_ITEM_ID, + ])); + expect(all.inventory.map((item) => item.id)).toEqual(["other"]); + expect(all.cost).toBeNull(); + expect(all.subscription).toBeNull(); + + const none = sections([]); + expect(projectClaudeSections("claude", none)).toBe(none); + }); + + it("leaves other providers and Admin API spend alone", () => { + const codex = sections([CLAUDE_RESET_CREDITS_ITEM_ID, CLAUDE_EXTRA_USAGE_ITEM_ID]); + expect(projectClaudeSections("codex", codex)).toBe(codex); + + const admin = { ...sections([CLAUDE_EXTRA_USAGE_ITEM_ID]), sourceLabel: "admin-api" }; + expect(projectClaudeSections("claude", admin).cost).not.toBeNull(); + }); +}); + +describe("claudePlanDateNote", () => { + it("keeps a calendar-only date on its own day", () => { + expect(claudePlanDateNote("claude", sections([]).subscription, en)).toBe( + "Renews: Dec 1, 2026", + ); + }); + + it("prefers the renewal, else reports the expiry", () => { + expect( + claudePlanDateNote( + "claude", + { + startsAt: null, + expiresAt: "2026-11-20T00:00:00+00:00", + renewsAt: null, + expiresAtIsDateOnly: true, + }, + en, + ), + ).toBe("Plan expires: Nov 20, 2026"); + }); + + it("never invents a date", () => { + expect(claudePlanDateNote("claude", null, en)).toBeNull(); + expect( + claudePlanDateNote("claude", { startsAt: null, expiresAt: null, renewsAt: null }, en), + ).toBeNull(); + expect(claudePlanDateNote("zed", sections([]).subscription, en)).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/lib/usageItemVisibility.ts b/apps/desktop-tauri/src/lib/usageItemVisibility.ts index b4d9c9344f6..de57407ab3e 100644 --- a/apps/desktop-tauri/src/lib/usageItemVisibility.ts +++ b/apps/desktop-tauri/src/lib/usageItemVisibility.ts @@ -1,6 +1,18 @@ +import type { + CostSnapshotBridge, + ProviderInventoryItem, + SubscriptionMetadataSnapshot, +} from "../types/bridge"; + export const USAGE_ITEM_METRIC_PREFIX = "metric:"; export const USAGE_ITEM_DETAIL_SECTION_PREFIX = "detailSection:"; +/** Non-metric card sections with their own choice (upstream 0.75.0 #4484). */ +export const CLAUDE_RESET_CREDITS_ITEM_ID = "section:claude-reset-credits"; +export const CLAUDE_EXTRA_USAGE_ITEM_ID = "section:claude-extra-usage"; +export const SUBSCRIPTION_ITEM_ID = "section:subscription"; +const CLAUDE_RESET_CREDITS_INVENTORY_ID = "reset-credits"; + /** Stable bridge ID for a metric or extra usage row. */ export function usageItemId(rawMetricId: string): string { return `${USAGE_ITEM_METRIC_PREFIX}${rawMetricId}`; @@ -25,3 +37,84 @@ export function isDetailSectionVisible( ): boolean { return !hiddenUsageItemIds?.includes(detailSectionItemId(rawSectionTitle)); } + +interface ClaudeSections { + inventory?: ProviderInventoryItem[]; + cost: CostSnapshotBridge | null; + subscription?: SubscriptionMetadataSnapshot | null; + sourceLabel: string | null; + hiddenUsageItemIds?: string[]; +} + +/** + * Claude's Extra usage / prepaid balance block. Admin API spend is a + * separate cost that this choice never hides (mirrors the backend rule). + */ +function isClaudeExtraUsageCost(value: ClaudeSections): boolean { + return !!value.cost && value.cost.alwaysVisible !== true && value.sourceLabel !== "admin-api"; +} + +/** + * Drop the Claude sections the user hid: saved reset credits, Extra usage / + * prepaid Credits, and plan dates. Provider-specific by design; every other + * provider passes through unchanged. + */ +export function projectClaudeSections(providerId: string, value: T): T { + if (providerId !== "claude") return value; + const hidden = value.hiddenUsageItemIds ?? []; + let projected = value; + if (hidden.includes(CLAUDE_RESET_CREDITS_ITEM_ID) && value.inventory?.length) { + projected = { + ...projected, + inventory: value.inventory.filter((item) => item.id !== CLAUDE_RESET_CREDITS_INVENTORY_ID), + }; + } + if (hidden.includes(CLAUDE_EXTRA_USAGE_ITEM_ID) && isClaudeExtraUsageCost(value)) { + projected = { ...projected, cost: null }; + } + if (hidden.includes(SUBSCRIPTION_ITEM_ID) && value.subscription) { + projected = { ...projected, subscription: null }; + } + return projected; +} + +type Translate = (key: "PlanRenewsOn" | "PlanExpiresOn" | "IntlLocale") => string; + +function formatPlanDate(raw: string, dateOnly: boolean, locale: string): string | null { + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return null; + const options: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + year: "numeric", + // A calendar-only date is stored as UTC midnight; local time could move it a day. + ...(dateOnly ? { timeZone: "UTC" } : {}), + }; + try { + return new Intl.DateTimeFormat(locale, options).format(date); + } catch { + return new Intl.DateTimeFormat("en-US", options).format(date); + } +} + +/** + * Upstream `subscriptionMetadataNotes`: one "Renews: " line, else + * "Plan expires: ". Claude only; it sits under the quota rows. + */ +export function claudePlanDateNote( + providerId: string, + subscription: SubscriptionMetadataSnapshot | null | undefined, + t: Translate, +): string | null { + if (providerId !== "claude" || !subscription) return null; + const locale = t("IntlLocale"); + if (subscription.renewsAt) { + const date = formatPlanDate(subscription.renewsAt, subscription.renewsAtIsDateOnly === true, locale); + if (date) return t("PlanRenewsOn").replace("{}", date); + } + if (subscription.expiresAt) { + const date = formatPlanDate(subscription.expiresAt, subscription.expiresAtIsDateOnly === true, locale); + if (date) return t("PlanExpiresOn").replace("{}", date); + } + return null; +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index b1062116f3d..a846d3e1753 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -416,6 +416,7 @@ export function ProviderDetailPane({ disabled={settingsDisabled} t={t} onChange={onSettingsChange} + costSummaryHidden={settings.costSummaryDisplayStyle === "hidden"} /> {hasFetchError && ( diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.test.tsx index 0c992d99dfb..39636430e56 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.test.tsx @@ -70,4 +70,42 @@ describe("UsageItemVisibilitySection", () => { providerHiddenUsageItemIds: { codex: [] }, }); }); + + it("explains a cost-gated row only while Cost summary display is Hidden", () => { + const claude = { + ...provider(), + id: "claude", + usageItems: [ + { id: "section:subscription", title: "Plan renewal / expiry", available: true }, + { + id: "section:claude-extra-usage", + title: "Extra usage", + available: true, + requiresCostSummary: true, + }, + ], + hiddenUsageItemIds: [], + }; + const onChange = vi.fn(); + const { rerender } = render( + key} onChange={onChange} />, + ); + expect(screen.queryByText("UsageItemHiddenByCostSummary")).not.toBeInTheDocument(); + + rerender( + key} + onChange={onChange} + costSummaryHidden + />, + ); + expect(screen.getAllByText("UsageItemHiddenByCostSummary")).toHaveLength(1); + // The choice stays usable; it does not override the global display style. + fireEvent.click(screen.getByRole("checkbox", { name: /Extra/ })); + expect(onChange).toHaveBeenCalledWith({ + providerHiddenUsageItemIds: { claude: ["section:claude-extra-usage"] }, + }); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.tsx index f4b94089aaf..f370ec2cfa6 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.tsx @@ -9,6 +9,8 @@ interface Props { disabled: boolean; t: (key: LocaleKey) => string; onChange: (patch: SettingsUpdate) => void; + /** Cost summary display is Hidden, which also suppresses cost-gated rows. */ + costSummaryHidden?: boolean; } /** @@ -21,6 +23,7 @@ export function UsageItemVisibilitySection({ disabled, t, onChange, + costSummaryHidden = false, }: Props) { const items = provider.usageItems ?? []; const [hidden, setHidden] = useState>( @@ -77,6 +80,11 @@ export function UsageItemVisibilitySection({ {t("ProviderUsageNotFetchedYet")} )} + {item.requiresCostSummary && costSummaryHidden && ( + + {t("UsageItemHiddenByCostSummary")} + + )} ))} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index 399910ba20c..bd9fc921fc0 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -10,7 +10,11 @@ import type { LocaleKey } from "../../../../i18n/keys"; import type { ResetTimeFormatMode } from "../../../../hooks/useFormattedResetTime"; import { useMonthlyLimitBlockNow } from "../../../../hooks/useMonthlyLimitBlockNow"; import type { MetricLane } from "../../../../lib/metricRowModel"; -import { isUsageItemVisible } from "../../../../lib/usageItemVisibility"; +import { + claudePlanDateNote, + isUsageItemVisible, + projectClaudeSections, +} from "../../../../lib/usageItemVisibility"; import { localizeProviderLabel, localizeWindowLabel } from "../../../../lib/windowLabels"; interface Props { @@ -41,7 +45,14 @@ interface BarSpec { * bars follow "Show usage as used", carry the quota-warning markers, and the * pace becomes the meta line under the bar. */ -export function UsageSection({ provider, resetTimeRelative, display, accentColor, t }: Props) { +export function UsageSection({ + provider: reportedProvider, + resetTimeRelative, + display, + accentColor, + t, +}: Props) { + const provider = projectClaudeSections(reportedProvider.id, reportedProvider); const bars: BarSpec[] = []; if (provider.session && isUsageItemVisible(provider.hiddenUsageItemIds, "primary")) { bars.push({ @@ -98,7 +109,13 @@ export function UsageSection({ provider, resetTimeRelative, display, accentColor const inventory = provider.inventory ?? []; const displayDetails = provider.displayDetails ?? []; const displayDetailGroups = groupProviderDisplayDetails(displayDetails); - if (shownBars.length === 0 && inventory.length === 0 && displayDetails.length === 0) { + const planDateNote = claudePlanDateNote(provider.id, provider.subscription, t); + if ( + shownBars.length === 0 && + !planDateNote && + inventory.length === 0 && + displayDetails.length === 0 + ) { return null; } @@ -119,6 +136,7 @@ export function UsageSection({ provider, resetTimeRelative, display, accentColor display={{ ...display, resetTimeRelative, monthlyLimitBlockNow }} /> ))} + {planDateNote &&
{planDateNote}
} {inventory.map((item) => ( .card-extra__caption { flex: none; diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index a44164075f6..ab9bf01824a 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -896,6 +896,9 @@ export interface SubscriptionMetadataSnapshot { startsAt: string | null; expiresAt: string | null; renewsAt: string | null; + /** Calendar-only dates are UTC midnight; format them in UTC. */ + renewsAtIsDateOnly?: boolean; + expiresAtIsDateOnly?: boolean; } export interface ProviderInventoryItem { @@ -925,6 +928,8 @@ export interface ProviderUsageItem { id: string; title: string; available: boolean; + /** Also suppressed while Cost summary display is Hidden. */ + requiresCostSummary?: boolean; } /** Backend-classified provider availability state (camelCase serde on the bridge). */ export type ProviderStateKind = @@ -1348,6 +1353,8 @@ export interface ProviderDetail { inventory?: ProviderInventoryItem[]; /** Provider-specific display rows; never used as quota math or persistence. */ displayDetails?: ProviderDisplayDetail[]; + /** Plan renewal / expiry dates for the usage preview. */ + subscription?: SubscriptionMetadataSnapshot | null; cost: CostSnapshotBridge | null; pace: PaceSnapshot | null; diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index e9afbd28998..3c772f384d1 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -22,6 +22,13 @@ pub struct SubscriptionMetadata { pub expires_at: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub renews_at: Option>, + /// The provider sent a calendar date (`YYYY-MM-DD`) for `renews_at`, + /// stored as UTC midnight; display it in UTC so it stays on that day. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub renews_at_is_date_only: bool, + /// Calendar-date precision for `expires_at`, as for `renews_at`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub expires_at_is_date_only: bool, } impl SubscriptionMetadata { @@ -34,9 +41,19 @@ impl SubscriptionMetadata { starts_at, expires_at, renews_at, + renews_at_is_date_only: false, + expires_at_is_date_only: false, } } + /// Mark which of the dates carry calendar-day precision only. + #[must_use] + pub const fn with_date_only(mut self, renews_at: bool, expires_at: bool) -> Self { + self.renews_at_is_date_only = renews_at; + self.expires_at_is_date_only = expires_at; + self + } + pub const fn is_empty(&self) -> bool { self.starts_at.is_none() && self.expires_at.is_none() && self.renews_at.is_none() } diff --git a/rust/src/locale.rs b/rust/src/locale.rs index cbae92a52d9..a6b2a385b96 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1983,6 +1983,12 @@ locale_keys! { AntigravityProfileHomesRemove, AntigravityProfileHomesEmpty, AntigravityProfileHomesPickFailed, + + // Claude optional usage rows and plan dates (upstream 0.75.0 #4484, #4324) + UsageItemPlanRenewalExpiry, + UsageItemHiddenByCostSummary, + PlanRenewsOn, + PlanExpiresOn, } #[cfg(test)] diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index c1e8886d455..b83dbe84c07 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1652,3 +1652,8 @@ AntigravityProfileHomesAdd = Add folder AntigravityProfileHomesRemove = Remove AntigravityProfileHomesEmpty = No additional profile folders added. AntigravityProfileHomesPickFailed = Could not open the folder picker. +# Claude optional usage rows and plan dates +UsageItemPlanRenewalExpiry = Plan renewal / expiry +UsageItemHiddenByCostSummary = Hidden while Cost summary display is set to Hidden. +PlanRenewsOn = Renews: { "{}" } +PlanExpiresOn = Plan expires: { "{}" } diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index bae82eec619..b8bce674f9f 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1649,3 +1649,8 @@ AntigravityProfileHomesAdd = Agregar carpeta AntigravityProfileHomesRemove = Quitar AntigravityProfileHomesEmpty = No hay carpetas de perfil adicionales. AntigravityProfileHomesPickFailed = No se pudo abrir el selector de carpetas. +# Claude optional usage rows and plan dates +UsageItemPlanRenewalExpiry = Renovación / vencimiento del plan +UsageItemHiddenByCostSummary = Oculto mientras el estilo de resumen de costos está en Oculto. +PlanRenewsOn = Se renueva: { "{}" } +PlanExpiresOn = El plan vence: { "{}" } diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index d22791f1f7c..b79d46f0b25 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1650,3 +1650,8 @@ AntigravityProfileHomesAdd = フォルダーを追加 AntigravityProfileHomesRemove = 削除 AntigravityProfileHomesEmpty = 追加のプロファイルフォルダーはありません。 AntigravityProfileHomesPickFailed = フォルダーの選択画面を開けませんでした。 +# Claude optional usage rows and plan dates +UsageItemPlanRenewalExpiry = プランの更新日 / 有効期限 +UsageItemHiddenByCostSummary = 費用サマリー表示スタイルが「非表示」の間は表示されません。 +PlanRenewsOn = 更新日: { "{}" } +PlanExpiresOn = プランの有効期限: { "{}" } diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 77ffaa9cc67..23b1fad68b8 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1649,3 +1649,8 @@ AntigravityProfileHomesAdd = 폴더 추가 AntigravityProfileHomesRemove = 제거 AntigravityProfileHomesEmpty = 추가된 프로필 폴더가 없습니다. AntigravityProfileHomesPickFailed = 폴더 선택 창을 열 수 없습니다. +# Claude optional usage rows and plan dates +UsageItemPlanRenewalExpiry = 요금제 갱신 / 만료 +UsageItemHiddenByCostSummary = 비용 요약 표시 스타일이 숨김이면 표시되지 않습니다. +PlanRenewsOn = 갱신: { "{}" } +PlanExpiresOn = 요금제 만료: { "{}" } diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index fe008149568..76bcab0ed79 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1654,3 +1654,8 @@ AntigravityProfileHomesAdd = Adicionar pasta AntigravityProfileHomesRemove = Remover AntigravityProfileHomesEmpty = Nenhuma pasta de perfil adicional. AntigravityProfileHomesPickFailed = Não foi possível abrir o seletor de pastas. +# Claude optional usage rows and plan dates +UsageItemPlanRenewalExpiry = Renovação / expiração do plano +UsageItemHiddenByCostSummary = Oculto enquanto a exibição do resumo de custos estiver em Oculto. +PlanRenewsOn = Renova: { "{}" } +PlanExpiresOn = O plano expira: { "{}" } diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 9ea0ed3af74..c8673d5fb85 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1650,3 +1650,8 @@ AntigravityProfileHomesAdd = Добавить папку AntigravityProfileHomesRemove = Удалить AntigravityProfileHomesEmpty = Дополнительные папки профилей не добавлены. AntigravityProfileHomesPickFailed = Не удалось открыть выбор папки. +# Claude optional usage rows and plan dates +UsageItemPlanRenewalExpiry = Продление / окончание тарифа +UsageItemHiddenByCostSummary = Скрыто, пока стиль отображения стоимости — «Скрытый». +PlanRenewsOn = Продление: { "{}" } +PlanExpiresOn = Тариф истекает: { "{}" } diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 2a34a0a062e..71698e3c5ba 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1650,3 +1650,8 @@ AntigravityProfileHomesAdd = Klasör ekle AntigravityProfileHomesRemove = Kaldır AntigravityProfileHomesEmpty = Eklenmiş ek profil klasörü yok. AntigravityProfileHomesPickFailed = Klasör seçici açılamadı. +# Claude optional usage rows and plan dates +UsageItemPlanRenewalExpiry = Plan yenileme / bitiş +UsageItemHiddenByCostSummary = Maliyet özeti görünümü Gizli olarak ayarlıyken gizlenir. +PlanRenewsOn = Yenilenme: { "{}" } +PlanExpiresOn = Plan bitişi: { "{}" } diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 73b364d4448..faa9aefef54 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1652,3 +1652,8 @@ CosmicProjectIdHelp = Required. Usage covers this project only. Or set COSMIC_PR ProviderLabelCloudCredits = Хмарні кредити ProviderTextExpired = Термін минув ProviderTextOfRemaining = Залишилося { "{}" } із { "{}" } +# Claude optional usage rows and plan dates +UsageItemPlanRenewalExpiry = Поновлення / завершення тарифу +UsageItemHiddenByCostSummary = Приховано, поки показ вартості встановлено на «Приховано». +PlanRenewsOn = Поновлення: { "{}" } +PlanExpiresOn = Тариф спливає: { "{}" } diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 5ec0c1e96b0..5711873033e 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1650,3 +1650,8 @@ AntigravityProfileHomesAdd = 添加文件夹 AntigravityProfileHomesRemove = 移除 AntigravityProfileHomesEmpty = 尚未添加额外的配置文件文件夹。 AntigravityProfileHomesPickFailed = 无法打开文件夹选择器。 +# Claude optional usage rows and plan dates +UsageItemPlanRenewalExpiry = 套餐续订 / 到期 +UsageItemHiddenByCostSummary = 费用摘要显示方式设为“隐藏”时不显示。 +PlanRenewsOn = 续订:{ "{}" } +PlanExpiresOn = 套餐到期:{ "{}" } diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index e911f0ff1d4..b519c31e713 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1650,3 +1650,8 @@ AntigravityProfileHomesAdd = 新增資料夾 AntigravityProfileHomesRemove = 移除 AntigravityProfileHomesEmpty = 尚未新增額外的設定檔資料夾。 AntigravityProfileHomesPickFailed = 無法開啟資料夾選擇器。 +# Claude optional usage rows and plan dates +UsageItemPlanRenewalExpiry = 方案續訂 / 到期 +UsageItemHiddenByCostSummary = 費用摘要顯示方式設為「隱藏」時不顯示。 +PlanRenewsOn = 續訂:{ "{}" } +PlanExpiresOn = 方案到期:{ "{}" } diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index b698307d50e..355b66082e2 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -13,6 +13,7 @@ mod reset_credits; pub mod reset_observations; mod scoped_weekly; mod spend_window; +mod subscription_details; mod trust_dialog; mod web_api; diff --git a/rust/src/providers/claude/subscription_details.rs b/rust/src/providers/claude/subscription_details.rs new file mode 100644 index 00000000000..8a1e2062ac4 --- /dev/null +++ b/rust/src/providers/claude/subscription_details.rs @@ -0,0 +1,222 @@ +//! Claude plan renewal / paid-access expiration dates from the authenticated +//! `GET /api/organizations/{org}/subscription_details` claude.ai response +//! (upstream CodexBar #4324). +//! +//! These dates are billing metadata, independent of quota-window resets. A +//! missing, unavailable, or unrecognized response never invents a date and +//! never fails the usage fetch. + +use chrono::{DateTime, NaiveDate, Utc}; +use serde_json::{Map, Value}; + +use crate::core::SubscriptionMetadata; + +/// The four date keys must all be present (each may be `null`); anything +/// else is an unrecognized schema. +const REQUIRED_KEYS: [&str; 4] = [ + "next_charge_at", + "next_charge_date", + "plan_ending_at", + "plan_ending_before", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BillingDate { + date: DateTime, + is_date_only: bool, +} + +/// Parse a subscription_details body. `None` means unrecognized; `Some` with +/// no dates is an authoritative "nothing to show". +pub(super) fn parse(body: &str) -> Option { + let Value::Object(fields) = serde_json::from_str::(body).ok()? else { + return None; + }; + let status = fields.get("status")?.as_str()?; + if !matches!(status, "active" | "trialing" | "canceled") { + return None; + } + if !REQUIRED_KEYS.iter().all(|key| fields.contains_key(*key)) { + return None; + } + + let end = first_date(&fields, "plan_ending_at", "plan_ending_before")?; + // A scheduled ending takes precedence over a next-charge value left in + // the same response. + let renewal = if end.is_none() && matches!(status, "active" | "trialing") { + first_date(&fields, "next_charge_at", "next_charge_date")? + } else { + None + }; + + Some( + SubscriptionMetadata::new(None, end.map(|d| d.date), renewal.map(|d| d.date)) + .with_date_only( + renewal.is_some_and(|d| d.is_date_only), + end.is_some_and(|d| d.is_date_only), + ), + ) +} + +/// The first non-null of two keys. The fallback is only read when the +/// preferred key is null. Outer `None` = malformed value. +fn first_date( + fields: &Map, + preferred: &str, + fallback: &str, +) -> Option> { + match date(fields, preferred)? { + Some(found) => Some(Some(found)), + None => date(fields, fallback), + } +} + +fn date(fields: &Map, key: &str) -> Option> { + let value = match fields.get(key) { + None | Some(Value::Null) => return Some(None), + Some(value) => value, + }; + let raw = value.as_str()?; + if raw.len() == 10 { + let day = NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()?; + // Reject lenient forms such as "2026-1-005" that re-format differently. + if day.format("%Y-%m-%d").to_string() != raw { + return None; + } + return Some(Some(BillingDate { + date: day.and_hms_opt(0, 0, 0)?.and_utc(), + is_date_only: true, + })); + } + let parsed = DateTime::parse_from_rfc3339(raw).ok()?; + Some(Some(BillingDate { + date: parsed.with_timezone(&Utc), + is_date_only: false, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn body( + status: &str, + next_at: &str, + next_date: &str, + end_at: &str, + end_before: &str, + ) -> String { + format!( + r#"{{"status":"{status}","next_charge_at":{next_at},"next_charge_date":{next_date},"plan_ending_at":{end_at},"plan_ending_before":{end_before}}}"# + ) + } + + #[test] + fn active_subscription_reports_timestamp_renewal() { + let metadata = parse(&body( + "active", + r#""2026-11-03T08:15:00Z""#, + "null", + "null", + "null", + )) + .expect("recognized"); + assert_eq!( + metadata.renews_at, + Some(Utc.with_ymd_and_hms(2026, 11, 3, 8, 15, 0).unwrap()) + ); + assert!(!metadata.renews_at_is_date_only); + assert_eq!(metadata.expires_at, None); + } + + #[test] + fn calendar_only_renewal_keeps_date_precision() { + let metadata = parse(&body("trialing", "null", r#""2026-12-01""#, "null", "null")) + .expect("recognized"); + assert_eq!( + metadata.renews_at, + Some(Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap()) + ); + assert!(metadata.renews_at_is_date_only); + } + + #[test] + fn scheduled_ending_takes_precedence_over_next_charge() { + let metadata = parse(&body( + "active", + r#""2026-11-03T08:15:00Z""#, + "null", + "null", + r#""2026-11-20""#, + )) + .expect("recognized"); + assert_eq!(metadata.renews_at, None); + assert_eq!( + metadata.expires_at, + Some(Utc.with_ymd_and_hms(2026, 11, 20, 0, 0, 0).unwrap()) + ); + assert!(metadata.expires_at_is_date_only); + assert!(!metadata.renews_at_is_date_only); + } + + #[test] + fn canceled_subscription_never_reports_renewal() { + let metadata = parse(&body( + "canceled", + r#""2026-11-03T08:15:00Z""#, + "null", + "null", + "null", + )) + .expect("recognized"); + assert!(metadata.is_empty()); + } + + #[test] + fn unrecognized_payloads_are_rejected() { + // Unknown status. + assert!(parse(&body("past_due", "null", "null", "null", "null")).is_none()); + // Missing required key. + assert!(parse(r#"{"status":"active","next_charge_at":null}"#).is_none()); + // Non-string date. + assert!(parse(&body("active", "12345", "null", "null", "null")).is_none()); + // Lenient or invalid calendar dates. + assert!(parse(&body("active", "null", r#""2026-02-30""#, "null", "null")).is_none()); + assert!(parse(&body("active", "null", r#""2026/12/01""#, "null", "null")).is_none()); + // Unparseable timestamp. + assert!(parse(&body("active", r#""tomorrow""#, "null", "null", "null")).is_none()); + assert!(parse("[]").is_none()); + assert!(parse("not json").is_none()); + } + + #[test] + fn preferred_value_wins_without_reading_fallback() { + // A malformed fallback is irrelevant when the preferred key has a date. + let metadata = parse(&body( + "active", + r#""2026-11-03T08:15:00+02:00""#, + "42", + "null", + "null", + )) + .expect("recognized"); + assert_eq!( + metadata.renews_at, + Some(Utc.with_ymd_and_hms(2026, 11, 3, 6, 15, 0).unwrap()) + ); + } + + #[test] + fn date_only_flags_round_trip_through_json() { + let metadata = + SubscriptionMetadata::new(None, None, Some(Utc::now())).with_date_only(true, false); + let json = serde_json::to_value(&metadata).unwrap(); + assert_eq!(json["renewsAtIsDateOnly"], true); + assert!(json.get("expiresAtIsDateOnly").is_none()); + let restored: SubscriptionMetadata = serde_json::from_value(json).unwrap(); + assert_eq!(restored, metadata); + let legacy: SubscriptionMetadata = serde_json::from_str("{}").unwrap(); + assert!(!legacy.renews_at_is_date_only && !legacy.expires_at_is_date_only); + } +} diff --git a/rust/src/providers/claude/subscription_details_wire_tests.rs b/rust/src/providers/claude/subscription_details_wire_tests.rs new file mode 100644 index 00000000000..8fac0fd1413 --- /dev/null +++ b/rust/src/providers/claude/subscription_details_wire_tests.rs @@ -0,0 +1,72 @@ +//! Wire tests for the optional Claude Web subscription_details request. + +use super::{AccountResponse, ClaudeWebApiFetcher}; +use mockito::{Server, ServerGuard}; + +const PATH: &str = "/organizations/org-123/subscription_details"; +const RENEWING_BODY: &str = r#"{"status":"active","next_charge_at":null, + "next_charge_date":"2026-12-01","plan_ending_at":null,"plan_ending_before":null}"#; + +async fn fetch(server: &ServerGuard) -> Option { + let fetcher = ClaudeWebApiFetcher::new().with_base_url(server.url()); + let headers = ClaudeWebApiFetcher::build_headers("sessionKey=sk-ant-fixture-token"); + fetcher.get_subscription_details("org-123", &headers).await +} + +#[tokio::test] +async fn recognized_response_yields_calendar_renewal() { + let mut server = Server::new_async().await; + let mock = server + .mock("GET", PATH) + .match_header("cookie", "sessionKey=sk-ant-fixture-token") + .with_status(200) + .with_body(RENEWING_BODY) + .expect(1) + .create_async() + .await; + + let metadata = fetch(&server).await.expect("metadata"); + + assert!(metadata.renews_at.is_some()); + assert!(metadata.renews_at_is_date_only); + mock.assert_async().await; +} + +#[tokio::test] +async fn unavailable_billing_access_yields_nothing() { + for status in [401, 403, 404, 500] { + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", PATH) + .with_status(status) + .with_body(RENEWING_BODY) + .create_async() + .await; + assert!(fetch(&server).await.is_none(), "status {status}"); + } +} + +#[tokio::test] +async fn malformed_body_yields_nothing() { + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", PATH) + .with_status(200) + .with_body(r#"{"status":"active"}"#) + .create_async() + .await; + assert!(fetch(&server).await.is_none()); +} + +#[test] +fn membership_check_requires_the_fetched_organization() { + let account: AccountResponse = serde_json::from_str( + r#"{"email_address":"a@example.com","memberships":[ + {"organization":{"uuid":"org-123"}}, + {"uuid":"org-legacy"}]}"#, + ) + .unwrap(); + assert!(account.is_member_of("org-123")); + assert!(!account.is_member_of("org-legacy")); + assert!(!account.is_member_of("org-other")); +} diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index fe0f9e1d204..1cb462fde99 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -13,6 +13,7 @@ use super::CLOUDFLARE_CHALLENGE_MESSAGE; use super::cloud_credits; use super::reset_credits; use super::spend_window; +use super::subscription_details; const CLOUDFLARE_BODY_PREFIX_BYTES: usize = 64 * 1024; @@ -295,6 +296,16 @@ struct AccountOrganization { } impl AccountResponse { + fn is_member_of(&self, org_id: &str) -> bool { + self.memberships.iter().any(|membership| { + membership + .organization + .as_ref() + .and_then(|organization| organization.uuid.as_deref()) + .is_some_and(|uuid| uuid.trim() == org_id) + }) + } + fn first_membership_org_id(&self) -> Option { self.memberships.iter().find_map(|membership| { membership @@ -419,8 +430,8 @@ impl ClaudeWebApiFetcher { // cookie source is explicitly off. let settings = crate::settings::Settings::load(); let cookie_source = settings.claude_cookie_source(); - if !cookie_source.eq_ignore_ascii_case("off") - && let Some(balance) = self.get_prepaid_credits(&org_id, &headers).await + let cookie_source_on = !cookie_source.eq_ignore_ascii_case("off"); + if cookie_source_on && let Some(balance) = self.get_prepaid_credits(&org_id, &headers).await { cost = Some(apply_prepaid_balance(balance, cost)); } @@ -429,6 +440,18 @@ impl ClaudeWebApiFetcher { result = result.with_cost(cost); } + // Best-effort plan renewal / expiration dates, only for an organization + // the authenticated account is a member of. + if cookie_source_on + && account + .as_ref() + .is_some_and(|account| account.is_member_of(&org_id)) + && let Some(metadata) = self.get_subscription_details(&org_id, &headers).await + && !metadata.is_empty() + { + result.usage = result.usage.with_subscription(Some(metadata)); + } + Ok(result) } @@ -646,6 +669,31 @@ impl ClaudeWebApiFetcher { parse_prepaid_balance(&body) } + /// Best-effort subscription dates with a two-second budget. Non-fatal. + async fn get_subscription_details( + &self, + org_id: &str, + headers: &reqwest::header::HeaderMap, + ) -> Option { + let url = format!( + "{}/organizations/{}/subscription_details", + self.base_url, org_id + ); + let response = self + .client + .get(&url) + .headers(headers.clone()) + .timeout(std::time::Duration::from_secs(2)) + .send() + .await + .ok()?; + if response.status() != StatusCode::OK { + return None; + } + let body = response.text().await.ok()?; + subscription_details::parse(&body) + } + /// Get account info async fn get_account_info( &self, @@ -1535,3 +1583,7 @@ mod cloudflare_tests; #[cfg(test)] #[path = "reset_opt_in_tests.rs"] mod reset_opt_in_tests; + +#[cfg(test)] +#[path = "subscription_details_wire_tests.rs"] +mod subscription_details_wire_tests; diff --git a/rust/src/settings.rs b/rust/src/settings.rs index dfc6c71013d..f553cdbba56 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -27,6 +27,15 @@ pub const CODEX_SPARK_USAGE_ITEM_IDS: [&str; 2] = [ "metric:extra-codex-spark-weekly", ]; pub const CLAUDE_DAILY_ROUTINES_USAGE_ITEM_ID: &str = "metric:extra-claude-routines"; +/// Namespace for non-metric card sections with their own visibility choice +/// (upstream 0.75.0 #4484). The raw IDs match upstream's. +pub const USAGE_ITEM_SECTION_PREFIX: &str = "section:"; +/// Claude saved limit-reset credits (the `reset-credits` inventory row). +pub const CLAUDE_RESET_CREDITS_USAGE_ITEM_ID: &str = "section:claude-reset-credits"; +/// Claude Extra usage / prepaid balance block. Admin API spend is excluded. +pub const CLAUDE_EXTRA_USAGE_USAGE_ITEM_ID: &str = "section:claude-extra-usage"; +/// Plan renewal / expiry dates shown beside the quota rows. +pub const SUBSCRIPTION_USAGE_ITEM_ID: &str = "section:subscription"; mod api_keys; mod config_path; @@ -660,7 +669,8 @@ fn normalize_hidden_usage_item_ids(ids: Vec) -> Vec { && id.len() <= 128 && !id.chars().any(char::is_control) && (id.starts_with(USAGE_ITEM_METRIC_PREFIX) - || id.starts_with(USAGE_ITEM_DETAIL_SECTION_PREFIX)) + || id.starts_with(USAGE_ITEM_DETAIL_SECTION_PREFIX) + || id.starts_with(USAGE_ITEM_SECTION_PREFIX)) }) .filter(|id| seen.insert(id.clone())) .collect::>(); diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 7fa5058402f..82f1548ce43 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -1840,6 +1840,76 @@ fn legacy_visibility_setters_preserve_other_explicit_hidden_items() { ); } +/// Windows never had upstream's global optional-usage gate, so Claude reset +/// credits, Extra usage and plan dates were all visible before the +/// independent choices existed. Upgrading must keep them visible and leave +/// existing choices untouched. +#[test] +fn upgrade_keeps_claude_optional_rows_visible_and_preserves_existing_choices() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + std::fs::write( + &path, + r#"{ "enabled_providers": ["claude"], + "provider_configs": { "claude": { "hidden_usage_item_ids": ["metric:secondary"] } } }"#, + ) + .unwrap(); + + let mut loaded = Settings::load_from_path(Some(&path)); + loaded.migrate_legacy_usage_item_flags(); + + assert_eq!( + loaded.hidden_usage_item_ids(ProviderId::Claude), + vec!["metric:secondary".to_string()] + ); + for id in [ + CLAUDE_RESET_CREDITS_USAGE_ITEM_ID, + CLAUDE_EXTRA_USAGE_USAGE_ITEM_ID, + SUBSCRIPTION_USAGE_ITEM_ID, + ] { + assert!( + !loaded + .hidden_usage_item_ids(ProviderId::Claude) + .contains(&id.to_string()) + ); + } + + let fresh = Settings::load_from_path(Some(&dir.path().join("missing.json"))); + assert!(fresh.hidden_usage_item_ids(ProviderId::Claude).is_empty()); +} + +#[test] +fn claude_section_choices_round_trip_independently_through_persistence() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let mut settings = Settings::default(); + settings.set_hidden_usage_item_ids( + ProviderId::Claude, + vec![ + SUBSCRIPTION_USAGE_ITEM_ID.to_string(), + CLAUDE_RESET_CREDITS_USAGE_ITEM_ID.to_string(), + "section:unknown-section".to_string(), + "bogus:claude-extra-usage".to_string(), + ], + ); + settings.save_to_path(&path).unwrap(); + + let loaded = Settings::load_from_path(Some(&path)); + assert_eq!( + loaded.hidden_usage_item_ids(ProviderId::Claude), + vec![ + CLAUDE_RESET_CREDITS_USAGE_ITEM_ID.to_string(), + SUBSCRIPTION_USAGE_ITEM_ID.to_string(), + "section:unknown-section".to_string(), + ] + ); + assert!( + !loaded + .hidden_usage_item_ids(ProviderId::Claude) + .contains(&CLAUDE_EXTRA_USAGE_USAGE_ITEM_ID.to_string()) + ); +} + /// Cookie-denial settings must survive the same path-based persistence used by /// `Settings::save` and `Settings::load`, including the secure-file wrapper /// shared by desktop and CLI settings.