diff --git a/changelog/unreleased/qoder-quota-sum-buckets.md b/changelog/unreleased/qoder-quota-sum-buckets.md new file mode 100644 index 0000000..11111da --- /dev/null +++ b/changelog/unreleased/qoder-quota-sum-buckets.md @@ -0,0 +1,7 @@ +### English + +- Qoder accounts now show their real remaining credits: the account card's headline sums the plan quota, the add-on pack, and the organization resource package instead of reading only the plan quota. Daily check-in rewards are credited to the add-on pack upstream, so before this a plan-only account's card never moved after a check-in (it stayed at e.g. 300 while the reward sat unshown). This matches how the Qoder client itself totals the buckets. + +### 中文 + +- Qoder 账号现在显示真实剩余额度:账号卡片的主数值改为把「套餐额度 + 加量包 + 组织资源包」三桶相加,而不是只读套餐额度。每日签到奖励在上游是记入加量包的,因此此前仅靠套餐额度的账号在签到后卡片数字始终不动(例如一直停在 300,奖励金额不显示)。这与 Qoder 客户端自身的合计口径一致。 diff --git a/internal/providers/qoder/quota.go b/internal/providers/qoder/quota.go index e7e05c6..0cdbf72 100644 --- a/internal/providers/qoder/quota.go +++ b/internal/providers/qoder/quota.go @@ -1,6 +1,10 @@ package qoder -import "github.com/caigee-cmd/cli2api/internal/accounts" +import ( + "math" + + "github.com/caigee-cmd/cli2api/internal/accounts" +) type workerQuota struct { UserQuota *workerQuotaBlock `json:"userQuota"` @@ -19,50 +23,98 @@ type workerQuotaBlock struct { Available *bool `json:"available"` } +// available reports whether the bucket may be spent. A nil Available means the +// upstream did not flag it unavailable. +func (w *workerQuotaBlock) available() bool { + return w != nil && (w.Available == nil || *w.Available) +} + func (w *workerQuotaBlock) hasRemaining() bool { - return w != nil && w.Remaining > 0 && (w.Available == nil || *w.Available) + return w.available() && w.Remaining > 0 } +// snapshot projects the upstream quota onto the console/routing model. +// +// Qoder reports credits in up to three buckets: the plan's monthly user quota, +// the add-on pack, and an organization resource package. The upstream CLI sums +// them (its remaining = userQuota + addOnQuota + orgResourcePackage) and its +// totalUsagePercentage is the same aggregate ratio, so the headline must be the +// sum of the available buckets. Daily check-in rewards land in the add-on +// bucket, which is exactly why a base-only headline never moved after a +// check-in (issue #229). Each bucket's detail fields are still reported. func (w *workerQuota) snapshot() *accounts.QuotaSnapshot { - if w == nil || w.UserQuota == nil { + if w == nil { return nil } + if w.UserQuota == nil && w.AddOnQuota == nil && w.OrgResourcePackage == nil { + // No bucket reported at all: unknown, not a fabricated zero. + return nil + } + + var used, total, remaining float64 + for _, bucket := range []*workerQuotaBlock{w.UserQuota, w.AddOnQuota, w.OrgResourcePackage} { + if !bucket.available() { + continue + } + used += bucket.Used + total += bucket.Total + remaining += bucket.Remaining + } + percentage := 0.0 + if total > 0 { + percentage = math.Min(used/total*100, 100) + } + snapshot := &accounts.QuotaSnapshot{ - Used: w.UserQuota.Used, - Total: w.UserQuota.Total, - Remaining: w.UserQuota.Remaining, - Percentage: w.UserQuota.Percentage, - Unit: w.UserQuota.Unit, - Exceeded: w.IsQuotaExceeded || w.UserQuota.Percentage >= 100, + Used: used, + Total: total, + Remaining: remaining, + Percentage: percentage, + Unit: headlineUnit(w), FetchedAt: w.FetchedAt, } - if snapshot.Unit == "" { - snapshot.Unit = "credits" + // Exceeded keeps its previous meaning: the account is out of credits unless + // an available add-on or organization package still holds some. + exceeded := w.IsQuotaExceeded || (w.UserQuota.available() && w.UserQuota.Percentage >= 100) + if exceeded && (w.AddOnQuota.hasRemaining() || w.OrgResourcePackage.hasRemaining()) { + exceeded = false } + snapshot.Exceeded = exceeded + if w.AddOnQuota != nil { snapshot.HasAddOn = true snapshot.AddOnUsed = w.AddOnQuota.Used snapshot.AddOnTotal = w.AddOnQuota.Total snapshot.AddOnRemaining = w.AddOnQuota.Remaining - snapshot.AddOnUnit = w.AddOnQuota.Unit + snapshot.AddOnUnit = blockUnit(w.AddOnQuota) snapshot.AddOnAvailable = w.AddOnQuota.Available - if snapshot.AddOnUnit == "" { - snapshot.AddOnUnit = "credits" - } } if w.OrgResourcePackage != nil { snapshot.HasResourcePackage = true snapshot.ResourcePackageUsed = w.OrgResourcePackage.Used snapshot.ResourcePackageTotal = w.OrgResourcePackage.Total snapshot.ResourcePackageRemaining = w.OrgResourcePackage.Remaining - snapshot.ResourcePackageUnit = w.OrgResourcePackage.Unit + snapshot.ResourcePackageUnit = blockUnit(w.OrgResourcePackage) snapshot.ResourcePackageAvailable = w.OrgResourcePackage.Available - if snapshot.ResourcePackageUnit == "" { - snapshot.ResourcePackageUnit = "credits" - } - } - if snapshot.Exceeded && (w.AddOnQuota.hasRemaining() || w.OrgResourcePackage.hasRemaining()) { - snapshot.Exceeded = false } return snapshot } + +func blockUnit(block *workerQuotaBlock) string { + if block == nil || block.Unit == "" { + return "credits" + } + return block.Unit +} + +// headlineUnit picks the unit for the aggregate from the first available +// bucket; every Qoder bucket is credit-denominated, so this only guards against +// an empty upstream value. +func headlineUnit(w *workerQuota) string { + for _, bucket := range []*workerQuotaBlock{w.UserQuota, w.AddOnQuota, w.OrgResourcePackage} { + if bucket.available() && bucket.Unit != "" { + return bucket.Unit + } + } + return "credits" +} diff --git a/internal/providers/qoder/quota_test.go b/internal/providers/qoder/quota_test.go index e0d2e6c..42ce0c8 100644 --- a/internal/providers/qoder/quota_test.go +++ b/internal/providers/qoder/quota_test.go @@ -2,22 +2,90 @@ package qoder import "testing" -func TestWorkerQuotaSnapshotUsesResourcePackage(t *testing.T) { +// A plan with a monthly quota plus a check-in add-on must sum both buckets; +// upstream reports totalUsagePercentage over the same aggregate, and the CLI +// sums the buckets too. This is issue #229: the add-on reward never moved the +// base-only headline. +func TestWorkerQuotaSnapshotSumsBuckets(t *testing.T) { quota := (&workerQuota{ - UserQuota: &workerQuotaBlock{Total: 100, Used: 100, Percentage: 100}, - OrgResourcePackage: &workerQuotaBlock{Total: 50, Used: 10, Remaining: 40, Unit: "credits"}, + UserQuota: &workerQuotaBlock{Total: 300, Used: 139, Remaining: 161, Percentage: 47, Unit: "credits"}, + AddOnQuota: &workerQuotaBlock{Total: 400, Used: 0, Remaining: 400, Percentage: 0, Unit: "credits"}, + IsQuotaExceeded: false, + FetchedAt: "now", + }).snapshot() + + if quota == nil { + t.Fatal("snapshot is nil") + } + if quota.Total != 700 || quota.Used != 139 || quota.Remaining != 561 { + t.Fatalf("headline must sum buckets: %+v", quota) + } + // 139 / 700 = 19.857% -> matches upstream totalUsagePercentage ~= 20. + if quota.Percentage < 19.8 || quota.Percentage > 19.9 { + t.Fatalf("percentage = %v, want ~19.86", quota.Percentage) + } + if quota.Exceeded { + t.Fatalf("account with add-on remaining must not be exceeded: %+v", quota) + } + if !quota.HasAddOn || quota.AddOnRemaining != 400 { + t.Fatalf("add-on detail must be preserved: %+v", quota) + } +} + +// A balance-only account (no subscription) reports userQuota 0/0/0 and keeps +// its credits entirely in the add-on bucket; the headline must show them. +func TestWorkerQuotaSnapshotBalanceOnlyAccount(t *testing.T) { + quota := (&workerQuota{ + UserQuota: &workerQuotaBlock{Total: 0, Used: 0, Remaining: 0, Percentage: 0, Unit: "credits"}, + AddOnQuota: &workerQuotaBlock{Total: 400, Used: 0, Remaining: 400, Percentage: 1, Unit: "credits"}, + }).snapshot() + + if quota == nil || quota.Total != 400 || quota.Remaining != 400 || quota.Used != 0 { + t.Fatalf("balance-only headline should follow the add-on: %+v", quota) + } +} + +// A consumed base quota with an available add-on keeps the account routable and +// shows the combined remainder. +func TestWorkerQuotaSnapshotBaseExhaustedAddOnAvailable(t *testing.T) { + quota := (&workerQuota{ + UserQuota: &workerQuotaBlock{Total: 100, Used: 100, Remaining: 0, Percentage: 100, Unit: "credits"}, + AddOnQuota: &workerQuotaBlock{Total: 50, Used: 10, Remaining: 40, Unit: "credits"}, + IsQuotaExceeded: true, + }).snapshot() + + if quota == nil || quota.Exceeded { + t.Fatalf("add-on remaining must keep Exceeded false: %+v", quota) + } + if quota.Remaining != 40 || quota.Total != 150 || quota.Used != 110 { + t.Fatalf("headline should be the combined buckets: %+v", quota) + } +} + +// An org resource package is included in the aggregate and can rescue an +// otherwise exhausted account. +func TestWorkerQuotaSnapshotIncludesResourcePackage(t *testing.T) { + available := true + quota := (&workerQuota{ + UserQuota: &workerQuotaBlock{Total: 100, Used: 100, Remaining: 0, Percentage: 100}, + OrgResourcePackage: &workerQuotaBlock{Total: 50, Used: 10, Remaining: 40, Percentage: 20, Available: &available}, IsQuotaExceeded: true, }).snapshot() if quota == nil || quota.Exceeded || !quota.HasResourcePackage || quota.ResourcePackageRemaining != 40 { t.Fatalf("quota = %+v", quota) } + if quota.Total != 150 || quota.Remaining != 40 { + t.Fatalf("resource package must join the aggregate: %+v", quota) + } } +// An unavailable resource package is excluded from the aggregate and cannot +// rescue an exhausted account. func TestWorkerQuotaSnapshotIgnoresUnavailableResourcePackage(t *testing.T) { available := false quota := (&workerQuota{ - UserQuota: &workerQuotaBlock{Total: 100, Used: 100, Percentage: 100}, + UserQuota: &workerQuotaBlock{Total: 100, Used: 100, Remaining: 0, Percentage: 100}, OrgResourcePackage: &workerQuotaBlock{ Total: 50, Remaining: 40, Available: &available, }, @@ -27,4 +95,14 @@ func TestWorkerQuotaSnapshotIgnoresUnavailableResourcePackage(t *testing.T) { if quota == nil || !quota.Exceeded || quota.ResourcePackageAvailable == nil || *quota.ResourcePackageAvailable { t.Fatalf("quota = %+v", quota) } + if quota.Total != 100 || quota.Remaining != 0 { + t.Fatalf("unavailable bucket must not count: %+v", quota) + } +} + +// No bucket reported at all is "unknown", never a fabricated zero. +func TestWorkerQuotaSnapshotNoBucketsIsNil(t *testing.T) { + if quota := (&workerQuota{}).snapshot(); quota != nil { + t.Fatalf("quota = %+v, want nil", quota) + } } diff --git a/internal/runtime/manager_test.go b/internal/runtime/manager_test.go index 3f78b6a..3b966ef 100644 --- a/internal/runtime/manager_test.go +++ b/internal/runtime/manager_test.go @@ -542,7 +542,7 @@ func TestManagerRefreshFetchesQuotaWithoutAffectingHealth(t *testing.T) { if item.Quota == nil { t.Fatalf("expected quota snapshot on pool item, got %+v", item) } - if item.Quota.Used != 150 || item.Quota.Total != 600 || item.Quota.Unit != "credits" || item.Quota.Exceeded { + if item.Quota.Used != 190 || item.Quota.Total != 700 || item.Quota.Unit != "credits" || item.Quota.Exceeded { t.Fatalf("quota snapshot = %+v", item.Quota) } if !item.Quota.HasAddOn || item.Quota.AddOnTotal != 100 || item.Quota.AddOnUsed != 40 { @@ -552,7 +552,7 @@ func TestManagerRefreshFetchesQuotaWithoutAffectingHealth(t *testing.T) { if err != nil { t.Fatal(err) } - if len(views) != 1 || views[0].Quota == nil || views[0].Quota.Remaining != 450 { + if len(views) != 1 || views[0].Quota == nil || views[0].Quota.Remaining != 510 { t.Fatalf("account view quota = %+v", views) } }