diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 76a66d65..eaaa5883 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1594,7 +1594,7 @@ function accountPlan(a) { if (a.agent === "gemini" || a.agent === "antigravity") return a.plan || "Google"; if (a.agent === "zcode") return a.plan || "GLM Coding Plan"; if (a.agent === "workbuddy") return a.plan || "WorkBuddy"; - if (a.agent === "commandcode-plan") return "Command Code" + (a.plan ? " " + a.plan : ""); + if (a.agent === "commandcode-plan") return "Command Code" + (a.plan ? " " + t(a.plan) : ""); return t("signed in"); } @@ -3765,8 +3765,15 @@ function accountQuota(data, user) { return line; } const q = data[user]; + // an account on no plan says so, whatever else can be read of it + const noPlan = q?.plan === "No plan" ? [t("No plan")] : []; + if (q && !q.error && !q.windows?.length && q.balance) { + // no rolling limits, only what is left to spend + line.append(el("span", "aq-none", [...noPlan, t("Balance") + " " + q.balance].join(" · "))); + return line; + } if (!q || q.error || !q.windows?.length) { - line.append(el("span", "aq-none", q?.error ? quotaError(q.error) : t("No usage reported"))); + line.append(el("span", "aq-none", [...noPlan, q?.error ? quotaError(q.error) : t("No usage reported")].join(" · "))); if (q?.error) line.title = q.error; return line; } diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index b3bf707d..6403db70 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -263,6 +263,7 @@ const I18N = { "Stop using this account": "停用这个账号", "Use this account too": "同时启用这个账号", "No usage reported": "暂无用量数据", + "No plan": "无订阅", "resets {in}": "{in}重置", "in {n}m": "{n} 分钟后", "in {n}h": "{n} 小时后", diff --git a/internal/provider/commandcode_plan.go b/internal/provider/commandcode_plan.go index 5ccb8b6f..c7eacff3 100644 --- a/internal/provider/commandcode_plan.go +++ b/internal/provider/commandcode_plan.go @@ -277,12 +277,47 @@ func cmdQuotaOf(q SubscriptionQuota, c cmdCredits) SubscriptionQuota { func cmdQuota(ctx context.Context, l Login, a cmdAuth) SubscriptionQuota { q := SubscriptionQuota{Provider: CommandCodePlanID, Name: "Command Code", Icon: "commandcode", Plan: l.Plan, User: l.User, Windows: []QuotaWindow{}} + // the plan is said even while the credits can't be read (Command Code + // answers 503, "Couldn't verify your credit balance just now", at times) + plan, until, renew, planOK := cmdSubscription(ctx, a) var c cmdCredits if err := accountJSON(ctx, cmdAPI+"/alpha/billing/credits", a.APIKey, nil, &c); err != nil { q.Error = err.Error() - return q + } else { + q = cmdQuotaOf(q, c) } - return cmdQuotaOf(q, c) + if planOK { + q.Plan, q.Until, q.Renew = plan, until, renew + } + return q +} + +// cmdNoPlan is the plan of an account with no subscription: its key is +// billed against the credits it bought, if any. +const cmdNoPlan = "No plan" + +// cmdSubscription is the account's plan, when its period ends and whether +// it renews then, as billing/subscriptions says; cmdNoPlan when it has +// none. ok is false when that can't be read. +func cmdSubscription(ctx context.Context, a cmdAuth) (plan string, until *time.Time, renew string, ok bool) { + var r struct { + Data *struct { + PlanID string `json:"planId"` + Status string `json:"status"` + CurrentPeriodEnd any `json:"currentPeriodEnd"` + CancelAtEnd *bool `json:"cancelAtPeriodEnd"` + } `json:"data"` + } + if accountJSON(ctx, cmdAPI+"/alpha/billing/subscriptions", a.APIKey, nil, &r) != nil { + return "", nil, "", false + } + if d := r.Data; d != nil && d.PlanID != "" && d.Status != "canceled" && d.Status != "incomplete_expired" { + if d.CancelAtEnd != nil { + renew = map[bool]string{true: "off", false: "auto"}[*d.CancelAtEnd] + } + return firstNonEmpty(cmdPlanName(d.PlanID), d.PlanID), cmdTime(d.CurrentPeriodEnd), renew, true + } + return cmdNoPlan, nil, "", true } func cmdLoginQuota(ctx context.Context, l Login) SubscriptionQuota { @@ -461,9 +496,8 @@ func cmdSignedIn(ctx context.Context, a cmdAuth) (who, plan string, err error) { if who == "" { return "", "", fmt.Errorf("Command Code didn't say which account signed in") } - var c cmdCredits - if accountJSON(ctx, cmdAPI+"/alpha/billing/credits", a.APIKey, nil, &c) == nil { - plan = cmdPlanName(c.Credits.PlanID) + if p, _, _, ok := cmdSubscription(ctx, a); ok { + plan = p } return who, plan, nil } diff --git a/internal/provider/commandcode_plan_test.go b/internal/provider/commandcode_plan_test.go index d0678593..7f66b9f4 100644 --- a/internal/provider/commandcode_plan_test.go +++ b/internal/provider/commandcode_plan_test.go @@ -29,6 +29,12 @@ func TestCommandCodePlan(t *testing.T) { switch r.URL.Path { case "/alpha/whoami": ok(map[string]any{"user": map[string]any{"id": "u2", "userName": "two", "email": "two@example.com"}}) + case "/alpha/billing/subscriptions": + if key == "own-key" { // as for an account with no plan + ok(map[string]any{"success": true, "data": nil}) + return + } + ok(map[string]any{"success": true, "data": map[string]any{"planId": "individual-max-monthly", "status": "active", "currentPeriodEnd": "2026-10-28T00:00:00Z", "cancelAtPeriodEnd": true}}) case "/alpha/billing/credits": ok(map[string]any{ "credits": map[string]any{"planId": "individual-max-monthly", "monthlyCredits": 150.5, "purchasedCredits": 10, "freeCredits": 0}, @@ -122,8 +128,12 @@ func TestCommandCodePlan(t *testing.T) { q.Windows[0].ResetsAt.Unix() != reset { t.Fatalf("usage: %+v", q) } - if !strings.Contains(q.Balance, "160.5") { - t.Fatalf("balance: %q", q.Balance) + if !strings.Contains(q.Balance, "160.5") || q.Until == nil || q.Until.Format("2006-01-02") != "2026-10-28" || q.Renew != "off" { + t.Fatalf("balance, period: %q %v", q.Balance, q.Until) + } + // an account with no subscription says so + if q := LoginUsage(context.Background(), CommandCodePlanID)["ownuser"]; q.Error != "" || q.Plan != "No plan" || q.Balance == "" { + t.Fatalf("no plan: %+v", q) } if err := ForgetLogin(CommandCodePlanID, "ownuser"); err == nil {