diff --git a/plugins/cursor-usage-source/server/usage-reported-pools.test.ts b/plugins/cursor-usage-source/server/usage-reported-pools.test.ts new file mode 100644 index 0000000000..04f672fcd3 --- /dev/null +++ b/plugins/cursor-usage-source/server/usage-reported-pools.test.ts @@ -0,0 +1,92 @@ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { fetchUsage } from "./usage.js"; + +it("shows the reported Pro percentage pools without treating bonus spend as an overage", async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-reported-pools-")); + const locator = join(directory, "auth.json"); + try { + await writeFile(locator, JSON.stringify({ accessToken: "fixture-token" })); + const report = await fetchUsage({ store: "file", locator }, async () => + Response.json({ + billingCycleStart: "2026-09-25T07:10:08.000Z", + billingCycleEnd: "2026-10-25T07:10:08.000Z", + planUsage: { + totalSpend: 5717, + includedSpend: 2000, + bonusSpend: 3717, + limit: 2000, + autoPercentUsed: 9.157777777777778, + apiPercentUsed: 35.46666666666667, + totalPercentUsed: 11.54949494949495, + }, + spendLimitUsage: { individualLimit: 100, individualRemaining: 100, limitType: "user" }, + }), + ); + expect(report).toMatchObject({ + status: "available", + windows: [ + { id: "plan_usage", usedPct: 11.54949494949495 }, + { id: "cursor_usage", usedPct: 9.157777777777778 }, + { id: "third_party_usage", usedPct: 35.46666666666667 }, + ], + balances: [], + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it.each([ + ["Pro+", 42015, 7000, 34.583333333333336, 4.6818181818181825, 32.07251908396947], + ["Teams", 77145, 2000, 61.8352380952381, 61.09, 61.716], +] as const)( + "preserves the reported %s pools separately from bonus-inclusive dollars", + async (_plan, totalSpend, limit, autoPercentUsed, apiPercentUsed, totalPercentUsed) => { + const report = await reportFor({ + totalSpend, + limit, + autoPercentUsed, + apiPercentUsed, + totalPercentUsed, + }); + expect(report).toMatchObject({ + status: "available", + windows: [ + { id: "plan_usage", usedPct: totalPercentUsed, summary: true }, + { id: "cursor_usage", usedPct: autoPercentUsed }, + { id: "third_party_usage", usedPct: apiPercentUsed }, + ], + balances: [], + }); + }, +); + +it("retains a zero percentage without inventing absent breakdowns", async () => { + expect(await reportFor({ totalPercentUsed: "0", autoPercentUsed: null })).toMatchObject({ + status: "available", + windows: [{ id: "plan_usage", usedPct: 0, remainingPct: 100 }], + balances: [], + }); +}); + +it("keeps dollar-only reports when the response has no percentages", async () => { + expect(await reportFor({ totalSpend: 1500, remaining: 2500, limit: 4000 })).toMatchObject({ + status: "available", + windows: [], + balances: [{ id: "plan_usage", used: 15, remaining: 25, limit: 40, unit: "usd" }], + }); +}); + +async function reportFor(planUsage: Record) { + const directory = await mkdtemp(join(tmpdir(), "cursor-usage-pools-")); + const locator = join(directory, "auth.json"); + try { + await writeFile(locator, JSON.stringify({ accessToken: "fixture-token" })); + return await fetchUsage({ store: "file", locator }, async () => Response.json({ planUsage })); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/plugins/cursor-usage-source/server/usage.ts b/plugins/cursor-usage-source/server/usage.ts index 2265dbb556..58d5b48ec2 100644 --- a/plugins/cursor-usage-source/server/usage.ts +++ b/plugins/cursor-usage-source/server/usage.ts @@ -9,10 +9,12 @@ import { toneFromUsedPct, usedPctOf, unavailable, + windowFromUsedPct, type UsageAccount, type UsageReport, type UsageBalance, type UsageScope, + type UsageWindow, } from "@getpaseo/plugin/server/usage"; const ApiNumberSchema = z.coerce.number().finite(); @@ -60,6 +62,9 @@ const CursorUsageResponseSchema = z.object({ bonusSpend: ApiNullableNumberSchema, remaining: ApiNullableNumberSchema, limit: ApiNullableNumberSchema, + totalPercentUsed: ApiNullableNumberSchema, + autoPercentUsed: ApiNullableNumberSchema, + apiPercentUsed: ApiNullableNumberSchema, }) .nullish(), billingCycleStart: CursorBillingCycleTimestampSchema, @@ -177,8 +182,29 @@ export async function fetchUsage( const resp = CursorUsageResponseSchema.parse(await res.json()); const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd); + const windows: UsageWindow[] = []; const balances: UsageBalance[] = []; if (resp.planUsage) { + // Total spend includes bonus credits; returned percentages measure quota usage. + for (const [id, label, usedPct] of [ + ["plan_usage", "Total", resp.planUsage.totalPercentUsed], + ["cursor_usage", "Cursor", resp.planUsage.autoPercentUsed], + ["third_party_usage", "Third Party", resp.planUsage.apiPercentUsed], + ] as const) { + if (usedPct === null) continue; + windows.push( + windowFromUsedPct({ + id, + label, + utilizationPct: usedPct, + resetsAt: billingCycleEnd, + summary: id === "plan_usage", + tone: toneFromUsedPct(usedPct), + }), + ); + } + } + if (resp.planUsage && windows.length === 0) { const totalSpend = centsToDollars(resp.planUsage.totalSpend); const remaining = centsToDollars(resp.planUsage.remaining); const limit = centsToDollars(resp.planUsage.limit); @@ -197,7 +223,7 @@ export async function fetchUsage( return { status: "available", planLabel: undefined, - windows: [], + windows, balances, details: [], };