feat(app): Plans settings detect subscriptions automatically

Detected subscriptions card (Claude/Codex): auto-detected tier from the
live quota with a connect hint when logged out — like the menubar,
never a fixed preset. Manual budget presets now only for providers
without OAuth quota (Cursor/SuperGrok); configured claude/codex manual
plans stay listed for removal with a superseded note. The Plans
dashboard already excludes them next to the authoritative live quota.
This commit is contained in:
iamtoruk 2026-07-16 05:11:10 -07:00
parent 0f836db72f
commit cee0f3501d
2 changed files with 72 additions and 10 deletions

View file

@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ActionResult, AliasRow, CombinedUsage, DeviceScanResult, Identity, MenubarPayload, PriceOverrideList, PriceRates, ShareStatus, StatusJson } from '../lib/types'
import type { ActionResult, AliasRow, CombinedUsage, DeviceScanResult, Identity, MenubarPayload, PriceOverrideList, PriceRates, QuotaProvider, ShareStatus, StatusJson } from '../lib/types'
import { Settings } from './Settings'
const mocks = vi.hoisted(() => ({
@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
getDevices: vi.fn<(period: string) => Promise<CombinedUsage>>(),
getDevicesScan: vi.fn<() => Promise<DeviceScanResult>>(),
getShareStatus: vi.fn<() => Promise<ShareStatus>>(),
getQuota: vi.fn<(force?: boolean) => Promise<QuotaProvider[]>>(),
getPlans: vi.fn<(period: string) => Promise<StatusJson>>(),
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
getAliases: vi.fn<() => Promise<AliasRow[]>>(),
@ -43,6 +44,10 @@ const devices: CombinedUsage = {
}
const scan: DeviceScanResult = { found: [{ name: 'Mac Studio', host: 'mac-studio.local', port: 9732, fingerprint: '7F:2A:19:88:55:44:33:C4', code: 'pair-1', paired: false }] }
const overview = { current: { providers: { claude: 12.34, codex: 4.5 } } } as unknown as MenubarPayload
const quotaProviders: QuotaProvider[] = [
{ provider: 'claude', connection: 'connected', primary: null, details: [], planLabel: 'Max 20x', footerLines: [] },
{ provider: 'codex', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
]
const stored = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (key: string) => stored.get(key) ?? null,
@ -57,6 +62,7 @@ describe('Settings', () => {
mocks.getDevices.mockResolvedValue(devices)
mocks.getDevicesScan.mockResolvedValue(scan)
mocks.getShareStatus.mockResolvedValue({ sharing: true, name: 'Toruk MacBook Pro', port: 9732, always: false, peers: 1, pending: [] })
mocks.getQuota.mockResolvedValue(quotaProviders)
mocks.getPlans.mockResolvedValue({ currency: 'EUR', today: { cost: 0, savings: 0, calls: 0 }, month: { cost: 0, savings: 0, calls: 0 }, plans: { claude: { id: 'claude-max', provider: 'claude', budget: 200, spent: 48, percentUsed: 24, status: 'under', projectedMonthEnd: 120, daysUntilReset: 19, periodStart: '2026-07-01', periodEnd: '2026-08-01' } } })
mocks.getOverview.mockResolvedValue(overview)
mocks.getAliases.mockResolvedValue([{ from: 'proxy-opus', to: 'claude-opus-4-6' }])
@ -207,6 +213,37 @@ describe('Settings', () => {
expect(mocks.setPlan).toHaveBeenCalledWith('cursor-pro', 'cursor')
})
it('shows detected subscriptions with an auto-detected tier and a disconnected hint', async () => {
const user = userEvent.setup()
render(<Settings period="month" />)
await user.click(screen.getByRole('button', { name: 'Plans' }))
expect(await screen.findByText('Detected subscriptions')).toBeInTheDocument()
expect(screen.getByText('Max 20x')).toBeInTheDocument()
expect(screen.getByText('Not connected: log in with the codex CLI')).toBeInTheDocument()
expect(mocks.getQuota).toHaveBeenCalledWith(false)
})
it('offers only non-OAuth budget presets; Claude and Codex are excluded', async () => {
const user = userEvent.setup()
render(<Settings period="month" />)
await user.click(screen.getByRole('button', { name: 'Plans' }))
await user.click(await screen.findByLabelText('Add a plan'))
expect(screen.getByRole('option', { name: 'Cursor Pro' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'SuperGrok' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: 'Claude Pro' })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: 'Claude Max 20x' })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: 'Claude Max 5x' })).not.toBeInTheDocument()
})
it('still lists a configured Claude manual plan with Remove and a superseded note', async () => {
const user = userEvent.setup()
render(<Settings period="month" />)
await user.click(screen.getByRole('button', { name: 'Plans' }))
expect((await screen.findAllByText('Claude Max 20x')).length).toBeGreaterThan(0)
expect(screen.getByText('superseded by the detected subscription')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
})
it('chooses an export folder and exports the selected format and provider', async () => {
const user = userEvent.setup()
render(<Settings period="month" />)

View file

@ -13,7 +13,7 @@ import { codeburn } from '../lib/ipc'
import { motionClass } from '../lib/motion'
import { showToast } from '../lib/toast'
import { ToastHost } from '../components/ToastHost'
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, ShareStatus, StatusJson } from '../lib/types'
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, ShareStatus, StatusJson } from '../lib/types'
export type SettingsPane = 'general' | 'providers' | 'aliases' | 'pricing' | 'plans' | 'devices' | 'export' | 'privacy'
type Pane = SettingsPane
@ -30,6 +30,10 @@ const PLAN_PRESETS: PlanPreset[] = [
{ id: 'supergrok-heavy', label: 'SuperGrok Heavy', provider: 'grok' },
]
// Claude and Codex subscriptions are detected from the CLI login (see the
// detected-subscriptions list), so only non-OAuth providers get a manual preset.
const MANUAL_PLAN_PRESETS = PLAN_PRESETS.filter(preset => preset.provider !== 'claude')
const CURRENCIES = [
'USD', 'EUR', 'GBP', 'JPY', 'CNY', 'CAD', 'AUD', 'CHF', 'HKD', 'SGD', 'INR', 'NZD', 'SEK', 'NOK', 'DKK',
'KRW', 'BRL', 'MXN', 'ZAR', 'AED', 'SAR', 'TRY', 'PLN', 'THB', 'IDR', 'MYR', 'PHP', 'RUB', 'ILS', 'CZK',
@ -295,10 +299,24 @@ function planSummaries(status: StatusJson): JsonPlanSummary[] {
return status.plan ? [status.plan] : []
}
function DetectedRow({ quota }: { quota: QuotaProvider }) {
const name = quota.provider === 'claude' ? 'Claude' : 'Codex'
return <div className="about-row">
<ProviderLogo provider={quota.provider} />
<span className="tx">{name}</span>
{quota.connection === 'disconnected'
? <span className="r set-status">Not connected: log in with the {quota.provider} CLI</span>
: <span className="r set-status"><span className="set-dot ok" />{quota.planLabel ?? 'Connected'}</span>}
</div>
}
function PlansPane({ period, refreshToken, onNavigate }: { period: Period; refreshToken: number; onNavigate?: (section: Section) => void }) {
const [nonce, setNonce] = useState(0)
// Match Plans.tsx: steady poll serves cached quota (force=false). The detected
// list is read-only, so no manual force is needed here.
const quota = usePolled<QuotaProvider[]>(() => codeburn.getQuota(false), [refreshToken])
const plans = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken, nonce])
const [presetId, setPresetId] = useState(PLAN_PRESETS[0]!.id)
const [presetId, setPresetId] = useState(MANUAL_PLAN_PRESETS[0]!.id)
const configured = plans.data ? planSummaries(plans.data) : []
const finish = (result: ActionResult) => {
@ -309,21 +327,28 @@ function PlansPane({ period, refreshToken, onNavigate }: { period: Period; refre
void codeburn.resetPlan(plan.provider).then(finish)
}
const add = () => {
const preset = PLAN_PRESETS.find(item => item.id === presetId)!
const preset = MANUAL_PLAN_PRESETS.find(item => item.id === presetId)!
void codeburn.setPlan(preset.id, preset.provider).then(finish)
}
return <section className="set-p on">
<div><h3 className="set-h">Plans</h3><p className="set-sub">Set a monthly budget plan per provider. codeburn compares it to your API-equivalent spend.</p></div>
<div><h3 className="set-h">Plans</h3><p className="set-sub">Claude and Codex subscriptions connect and auto-detect your tier. Set a manual budget plan for any other provider.</p></div>
<div className="card">
<div className="about-sec">
{plans.error ? <SettingsErrorText error={plans.error} /> : !plans.data ? <p className="set-cap">Loading plans</p> : configured.length === 0 ? <p className="set-cap">No plans configured.</p> : configured.map(plan => <div className="about-row" key={plan.provider}><span className="tx">{PLAN_PRESETS.find(item => item.id === plan.id)?.label ?? plan.id}<small>{formatConverted(plan.budget)}/month · {plan.provider} · {plan.percentUsed}% used</small></span><span className="r"><ConfirmButton label="Remove" prompt="Remove?" onConfirm={() => remove(plan)} /></span></div>)}
</div>
<div className="about-sec set-last-sec">
<div className="about-row"><label className="tx" htmlFor="settings-plan-preset">Add a plan</label><span className="r"><Dropdown id="settings-plan-preset" ariaLabel="Add a plan" value={presetId} options={PLAN_PRESETS.map(preset => ({ value: preset.id, label: preset.label }))} onChange={value => setPresetId(value as PlanPreset['id'])} width={160} /><button className="btnp btnp-primary" onClick={add}>Add</button></span></div>
<div className="about-sec-h">Detected subscriptions</div>
{quota.error && !quota.data ? <SettingsErrorText error={quota.error} /> : !quota.data ? <p className="set-cap">Detecting subscriptions</p> : quota.data.length === 0 ? <p className="set-cap">No detectable subscriptions.</p> : quota.data.map(provider => <DetectedRow key={provider.provider} quota={provider} />)}
</div>
</div>
<p className="set-cap">Presets: Claude Pro, Claude Max 20x, Claude Max 5x, Cursor Pro, SuperGrok, and SuperGrok Heavy. <button className="set-text-button" onClick={() => onNavigate?.('plans')}>Open Plans </button></p>
<div className="card">
<div className="about-sec">
<div className="about-sec-h">Budget plans (manual)</div>
{plans.error ? <SettingsErrorText error={plans.error} /> : !plans.data ? <p className="set-cap">Loading plans</p> : configured.length === 0 ? <p className="set-cap">No manual plans configured.</p> : configured.map(plan => <div className="about-row" key={plan.provider}><span className="tx">{PLAN_PRESETS.find(item => item.id === plan.id)?.label ?? plan.id}<small>{formatConverted(plan.budget)}/month · {plan.provider} · {plan.percentUsed}% used</small>{(plan.provider === 'claude' || plan.provider === 'codex') && <small>superseded by the detected subscription</small>}</span><span className="r"><ConfirmButton label="Remove" prompt="Remove?" onConfirm={() => remove(plan)} /></span></div>)}
</div>
<div className="about-sec set-last-sec">
<div className="about-row"><label className="tx" htmlFor="settings-plan-preset">Add a plan</label><span className="r"><Dropdown id="settings-plan-preset" ariaLabel="Add a plan" value={presetId} options={MANUAL_PLAN_PRESETS.map(preset => ({ value: preset.id, label: preset.label }))} onChange={value => setPresetId(value as PlanPreset['id'])} width={160} /><button className="btnp btnp-primary" onClick={add}>Add</button></span></div>
</div>
</div>
<p className="set-cap">Claude and Codex plans are detected automatically from your login. <button className="set-text-button" onClick={() => onNavigate?.('plans')}>Open Plans </button></p>
</section>
}