diff --git a/mac/Sources/CodeBurnMenubar/Data/CommandCodeSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/CommandCodeSubscriptionService.swift index 4859c64a..2b5d98af 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CommandCodeSubscriptionService.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CommandCodeSubscriptionService.swift @@ -4,12 +4,23 @@ import Foundation /// Command Code CLI keeps in ~/.commandcode/auth.json (read-only, never logged). /// GET /alpha/billing/credits carries the 5-hour and weekly windows as USD /// credits used of a cap (resetAt epoch ms, 0 while no window is open) plus -/// the remaining credits; GET /alpha/billing/subscriptions carries the plan id. -/// Mirrors src/quota/commandcode.ts. +/// the remaining credits; GET /alpha/billing/subscriptions carries the plan id, +/// status and period end. Mirrors src/quota/commandcode.ts. enum CommandCodeSubscriptionService { static let creditsURL = URL(string: "https://api.commandcode.ai/alpha/billing/credits")! static let subscriptionsURL = URL(string: "https://api.commandcode.ai/alpha/billing/subscriptions")! private static let timeoutSeconds: TimeInterval = 15 + // Command Code's API gives no monthly cap, so the plan price comes from this table. + static let planMonthlyUSD: [String: Double] = [ + "individual-go": 10, "individual-go-v1": 10, "individual-goat": 70, "individual-pro": 30, "individual-pro-v1": 80, + "individual-provider": 15, "individual-max": 150, "individual-ultra": 300, "teams-pro": 40, + ] + + struct Subscription: Equatable, Sendable { + var planID: String? + var status: String? + var currentPeriodEnd: String? + } enum FetchError: Error, Equatable, LocalizedError, Sendable { case noCredentials @@ -117,29 +128,34 @@ enum CommandCodeSubscriptionService { default: throw FetchError.parseFailure } - var planID: String? + var plan = Subscription() if let (body, http) = await subscription, http.statusCode == 200, - let root = try? JSONSerialization.jsonObject(with: body) as? [String: Any] { - planID = (root["data"] as? [String: Any])?["planId"] as? String + let root = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let row = root["data"] as? [String: Any] { + plan = Subscription( + planID: row["planId"] as? String, + status: row["status"] as? String, + currentPeriodEnd: row["currentPeriodEnd"] as? String + ) } - return try decode(data, planID: planID) + return try decode(data, subscription: plan) } - static func decode(_ data: Data, planID: String? = nil) throws -> QuotaSummary { + static func decode(_ data: Data, subscription: Subscription = Subscription()) throws -> QuotaSummary { guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw FetchError.parseFailure } let limits = root["windowLimits"] as? [String: Any] ?? [:] let fiveHour = window("5-hour", limits["fiveHour"]) let weekly = window("Weekly", limits["weekly"]) - let details = [fiveHour, weekly].compactMap { $0 } - guard !details.isEmpty else { throw FetchError.parseFailure } + guard fiveHour != nil || weekly != nil else { throw FetchError.parseFailure } + let details = [fiveHour, weekly, monthlyWindow(root["credits"], subscription)].compactMap { $0 } return QuotaSummary( providerFilter: .all, connection: .connected, primary: weekly ?? fiveHour, details: details, - planLabel: planLabel(planID), + planLabel: planLabel(subscription.planID), footerLines: creditsLine(root["credits"]).map { [$0] } ?? [] ) } @@ -164,6 +180,27 @@ enum CommandCodeSubscriptionService { ) } + /// Monthly credits used of the plan price; purchased and free credits are not part of the plan. + private static func monthlyWindow(_ credits: Any?, _ subscription: Subscription) -> QuotaSummary.Window? { + guard subscription.status == "active", + let planID = subscription.planID, let plan = planMonthlyUSD[planID], + let left = number((credits as? [String: Any])?["monthlyCredits"]) else { return nil } + let pool = max(plan, left) + return QuotaSummary.Window( + label: "Monthly", + percent: min(1, max(0, (pool - left) / pool)), + resetsAt: subscription.currentPeriodEnd.flatMap(parseDate) + ) + } + + private static func parseDate(_ raw: String) -> Date? { + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso.date(from: raw) { return date } + iso.formatOptions = [.withInternetDateTime] + return iso.date(from: raw) + } + private static func creditsLine(_ raw: Any?) -> String? { guard let credits = raw as? [String: Any] else { return nil } let parts = [("monthlyCredits", "monthly"), ("purchasedCredits", "purchased"), ("freeCredits", "free")] diff --git a/mac/Tests/CodeBurnMenubarTests/CommandCodeQuotaTests.swift b/mac/Tests/CodeBurnMenubarTests/CommandCodeQuotaTests.swift index a1184acd..e4b0ca53 100644 --- a/mac/Tests/CodeBurnMenubarTests/CommandCodeQuotaTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CommandCodeQuotaTests.swift @@ -16,7 +16,7 @@ struct CommandCodeQuotaTests { "fiveHour":{"used":0.75,"cap":3,"exceeded":false,"resetAt":0}, "weekly":{"used":6.0293354601,"cap":6,"exceeded":true,"resetAt":1791653049551}}} """ - nonisolated private static let subscriptionBody = #"{"success":true,"data":{"status":"active","planId":"individual-go-v1"}}"# + nonisolated private static let subscriptionBody = #"{"success":true,"data":{"status":"active","planId":"individual-go-v1","currentPeriodEnd":"2026-11-01T10:38:29.000Z"}}"# nonisolated private static func response(_ request: URLRequest, _ status: Int) -> HTTPURLResponse { HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! @@ -43,10 +43,13 @@ struct CommandCodeQuotaTests { let body = request.url == CommandCodeSubscriptionService.creditsURL ? Self.creditsBody : Self.subscriptionBody return (Data(body.utf8), Self.response(request, 200)) }) - #expect(summary.details.map(\.label) == ["5-hour", "Weekly"]) - #expect(summary.details.map(\.percent) == [0.25, 1]) + #expect(summary.details.map(\.label) == ["5-hour", "Weekly", "Monthly"]) + #expect(summary.details[0].percent == 0.25) + #expect(summary.details[1].percent == 1) + #expect(abs(summary.details[2].percent - 0.6029) < 0.0001) #expect(summary.details[0].resetsAt == nil) #expect(summary.details[1].resetsAt == Date(timeIntervalSince1970: 1_791_653_049.551)) + #expect(summary.details[2].resetsAt == Date(timeIntervalSince1970: 1_793_529_509)) #expect(summary.primary?.label == "Weekly") #expect(summary.planLabel == "Go") #expect(summary.footerLines == ["Credits left: $3.97 monthly"]) @@ -57,6 +60,23 @@ struct CommandCodeQuotaTests { } } + @Test("the monthly window needs an active subscription on a known plan") + func monthlyNeedsKnownActivePlan() throws { + let body = Data(Self.creditsBody.utf8) + let known = CommandCodeSubscriptionService.Subscription( + planID: "individual-go-v1", status: "active", currentPeriodEnd: "2026-11-01T10:38:29Z") + let go = try CommandCodeSubscriptionService.decode(body, subscription: known) + #expect(go.details.last?.label == "Monthly") + #expect(go.details.last?.resetsAt == Date(timeIntervalSince1970: 1_793_529_509)) + #expect(go.primary?.label == "Weekly") + var unknown = known + unknown.planID = "individual-mystery" + #expect(try CommandCodeSubscriptionService.decode(body, subscription: unknown).details.map(\.label) == ["5-hour", "Weekly"]) + var inactive = known + inactive.status = "canceled" + #expect(try CommandCodeSubscriptionService.decode(body, subscription: inactive).details.map(\.label) == ["5-hour", "Weekly"]) + } + @Test("a failed subscription call only drops the plan label") func subscriptionFailureKeepsWindows() async throws { let summary = try await CommandCodeSubscriptionService.refresh(deps: Self.deps { request in diff --git a/src/quota/commandcode.ts b/src/quota/commandcode.ts index 49b66faf..acae991b 100644 --- a/src/quota/commandcode.ts +++ b/src/quota/commandcode.ts @@ -5,7 +5,8 @@ // the window's cap; resetAt is epoch ms, 0 while no window is open. // credits.{monthlyCredits,purchasedCredits,freeCredits}: USD remaining. // - GET https://api.commandcode.ai/alpha/billing/subscriptions -// data.planId for the plan label; optional, a failure only drops the label. +// data.{planId,status,currentPeriodEnd} for the plan label and the monthly +// window; optional, a failure only drops both. // // Credential: the CLI's own ~/.commandcode/auth.json `apiKey`, read-only. // The API refuses requests without a User-Agent, so one is always sent. @@ -20,6 +21,11 @@ const EXPIRED_FOOTER = ['Command Code session expired. Sign in with the Command const RATE_LIMITED_FOOTER = ['Command Code rate-limited the quota request.'] const UNAVAILABLE_FOOTER = ['Command Code is temporarily unavailable.'] const PARSE_FOOTER = ['Command Code quota response was malformed.'] +// Command Code's API gives no monthly cap, so the plan price comes from this table. +const PLAN_MONTHLY_USD: Record = { + 'individual-go': 10, 'individual-go-v1': 10, 'individual-goat': 70, 'individual-pro': 30, 'individual-pro-v1': 80, + 'individual-provider': 15, 'individual-max': 150, 'individual-ultra': 300, 'teams-pro': 40, +} export type CommandCodeDeps = { fetch: typeof fetch @@ -73,8 +79,25 @@ function creditsNote(raw: unknown): string | null { return `Credits left: ${shown.map(part => `$${part.value.toFixed(2)} ${part.name}`).join(', ')}` } -/** `null` when neither window is readable. */ -export function decodeCommandCodeCredits(body: unknown, planId?: unknown): QuotaProvider | null { +export type CommandCodeSubscription = { planId?: unknown; status?: unknown; currentPeriodEnd?: unknown } + +/** Monthly credits used of the plan price; purchased and free credits are not part of the plan. */ +function monthlyWindow(credits: unknown, subscription: CommandCodeSubscription): QuotaWindow | null { + if (subscription.status !== 'active' || typeof subscription.planId !== 'string') return null + const plan = Object.hasOwn(PLAN_MONTHLY_USD, subscription.planId) ? PLAN_MONTHLY_USD[subscription.planId]! : null + const left = credits && typeof credits === 'object' ? num((credits as Record)['monthlyCredits']) : null + if (plan === null || left === null) return null + const pool = Math.max(plan, left) + const end = typeof subscription.currentPeriodEnd === 'string' ? Date.parse(subscription.currentPeriodEnd) : NaN + return { + label: 'Monthly', + percent: fraction((pool - left) / pool * 100)!, + resetsAt: Number.isFinite(end) ? new Date(end).toISOString() : null, + } +} + +/** `null` when neither the 5-hour nor the weekly window is readable. */ +export function decodeCommandCodeCredits(body: unknown, subscription: CommandCodeSubscription = {}): QuotaProvider | null { if (!body || typeof body !== 'object') return null const root = body as Record const limits = root['windowLimits'] && typeof root['windowLimits'] === 'object' @@ -82,14 +105,15 @@ export function decodeCommandCodeCredits(body: unknown, planId?: unknown): Quota : {} const fiveHour = windowOf('5-hour', limits['fiveHour']) const weekly = windowOf('Weekly', limits['weekly']) - const details = [fiveHour, weekly].filter((row): row is QuotaWindow => row !== null) - if (details.length === 0) return null + if (!fiveHour && !weekly) return null + const details = [fiveHour, weekly, monthlyWindow(root['credits'], subscription)] + .filter((row): row is QuotaWindow => row !== null) const note = creditsNote(root['credits']) return { provider: 'commandcode', connection: 'connected', primary: weekly ?? fiveHour!, details, - planLabel: commandCodePlanLabel(planId), + planLabel: commandCodePlanLabel(subscription.planId), footerLines: note ? [note] : [], ...(note ? { notes: [note] } : {}), } @@ -128,10 +152,10 @@ export async function fetchCommandCodeQuota(options: Partial & if (credits.status >= 500) return { quota: empty('transientFailure', UNAVAILABLE_FOOTER) } if (!credits.ok) return { quota: empty('transientFailure', PARSE_FOOTER) } // Never log either body - they carry account data. - const planId = subscription?.ok - ? await subscription.json().then(body => (body as { data?: { planId?: unknown } })?.data?.planId, () => undefined) + const plan = subscription?.ok + ? await subscription.json().then(body => (body as { data?: CommandCodeSubscription })?.data, () => undefined) : undefined - const quota = decodeCommandCodeCredits(await credits.json(), planId) + const quota = decodeCommandCodeCredits(await credits.json(), plan && typeof plan === 'object' ? plan : {}) return { quota: quota ?? empty('transientFailure', PARSE_FOOTER) } } catch (error) { console.warn(`Command Code quota unavailable: ${sanitizeError(error)}`) diff --git a/tests/quota-commandcode.test.ts b/tests/quota-commandcode.test.ts index d414c892..8d2992df 100644 --- a/tests/quota-commandcode.test.ts +++ b/tests/quota-commandcode.test.ts @@ -25,7 +25,7 @@ function jsonResponse(body: unknown, status = 200): Response { describe('Command Code quota decoding', () => { it('maps the 5-hour and weekly windows as used of cap, clamped', () => { - const quota = decodeCommandCodeCredits(creditsBody, 'individual-go-v1') + const quota = decodeCommandCodeCredits(creditsBody, { planId: 'individual-go-v1' }) expect(quota?.connection).toBe('connected') expect(quota?.details).toEqual([ { label: '5-hour', percent: 0.25, resetsAt: null }, @@ -36,6 +36,33 @@ describe('Command Code quota decoding', () => { expect(quota?.notes).toEqual(['Credits left: $3.97 monthly']) }) + it('adds the monthly window from the plan price for an active known plan', () => { + const body = { ...creditsBody, windowLimits: { ...creditsBody.windowLimits, fiveHour: { used: 0, cap: 3, exceeded: false, resetAt: 0 } } } + const quota = decodeCommandCodeCredits(body, subscriptionBody.data) + expect(quota?.details.map(row => [row.label, row.resetsAt])).toEqual([ + ['5-hour', null], + ['Weekly', '2026-10-10T17:24:09.551Z'], + ['Monthly', new Date('2026-11-01T10:38:29.000Z').toISOString()], + ]) + expect(quota?.details[0]!.percent).toBe(0) + expect(quota?.details[1]!.percent).toBe(1) + expect(quota?.details[2]!.percent).toBeCloseTo(0.6029, 4) + expect(quota?.primary?.label).toBe('Weekly') + }) + + it('leaves out the monthly window for an unknown plan or an inactive subscription', () => { + const labels = (subscription: object) => decodeCommandCodeCredits(creditsBody, subscription)?.details.map(row => row.label) + expect(labels({ ...subscriptionBody.data, planId: 'individual-mystery' })).toEqual(['5-hour', 'Weekly']) + expect(labels({ ...subscriptionBody.data, planId: 'toString' })).toEqual(['5-hour', 'Weekly']) + expect(labels({ ...subscriptionBody.data, status: 'canceled' })).toEqual(['5-hour', 'Weekly']) + expect(labels({})).toEqual(['5-hour', 'Weekly']) + }) + + it('uses the larger of plan price and credits left as the pool', () => { + const body = { ...creditsBody, credits: { monthlyCredits: 12 } } + expect(decodeCommandCodeCredits(body, subscriptionBody.data)?.details[2]).toMatchObject({ label: 'Monthly', percent: 0 }) + }) + it('skips an unreadable window and rejects a body with none', () => { const quota = decodeCommandCodeCredits({ windowLimits: { fiveHour: { used: 1, cap: 0 }, weekly: { used: 3, cap: 6, resetAt: 0 } } }) expect(quota?.details).toEqual([{ label: 'Weekly', percent: 0.5, resetsAt: null }]) @@ -70,6 +97,7 @@ describe('Command Code quota fetch', () => { }) expect(result.quota.connection).toBe('connected') expect(result.quota.planLabel).toBe('Go') + expect(result.quota.details.map(row => row.label)).toEqual(['5-hour', 'Weekly', 'Monthly']) expect(seen.map(row => row.url).sort()).toEqual([ 'https://api.commandcode.ai/alpha/billing/credits', 'https://api.commandcode.ai/alpha/billing/subscriptions', @@ -87,6 +115,7 @@ describe('Command Code quota fetch', () => { }) expect(result.quota.connection).toBe('connected') expect(result.quota.planLabel).toBeNull() + expect(result.quota.details.map(row => row.label)).toEqual(['5-hour', 'Weekly']) }) it('maps HTTP failures', async () => {