mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix(codebuddy-cn): show bonus packs as one-time, not monthly-replenishing
CodeBuddy CN bonus packs ("Bonus Pack N") are one-shot credits whose
CycleEndTime equals DeductionEndTime — they expire for good and never
replenish. The dashboard rendered their resetAt as "Reset in Xd",
implying a monthly refill.
Tag bonus packs recurring:false (refill packs recurring:true) in the
usage handler, forward the flag through parseQuotaData, and word the
quota table / progress bar as "Expires in" / "Expires at" for
one-shot packs.
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
- Support Gemini native TTS generateContent endpoint — nguyenha935
|
- Support Gemini native TTS generateContent endpoint — nguyenha935
|
||||||
- Add missing zh-CN endpoint key label (i18n) — weimaozhen
|
- Add missing zh-CN endpoint key label (i18n) — weimaozhen
|
||||||
- CodeBuddy: only send reasoning params when client requests reasoning (#2071) — Rex
|
- CodeBuddy: only send reasoning params when client requests reasoning (#2071) — Rex
|
||||||
|
- CodeBuddy CN: show one-shot bonus packs as expiring, not monthly-replenishing
|
||||||
- Show custom provider models in combo picker — Sapto
|
- Show custom provider models in combo picker — Sapto
|
||||||
- Docker: add docker-compose.yml with headroom enabled by default — nitsuahlabs
|
- Docker: add docker-compose.yml with headroom enabled by default — nitsuahlabs
|
||||||
- Clarify token diagnostics vs provider billing (headroom, #1998) — Sutarto Jordan Chrisfivo
|
- Clarify token diagnostics vs provider billing (headroom, #1998) — Sutarto Jordan Chrisfivo
|
||||||
|
|||||||
@@ -109,15 +109,22 @@ export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificD
|
|||||||
total: num(acc.CycleCapacitySizePrecise, acc.CycleCapacitySize),
|
total: num(acc.CycleCapacitySizePrecise, acc.CycleCapacitySize),
|
||||||
resetAt: parseResetTime(acc.CycleEndTime),
|
resetAt: parseResetTime(acc.CycleEndTime),
|
||||||
unlimited: false,
|
unlimited: false,
|
||||||
|
// Recurring allowance: the CycleEndTime is the next refresh, not the
|
||||||
|
// final expiry. The UI must show "Resets in", not "Expires in".
|
||||||
|
recurring: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
// Bonus packs: use the lifetime Capacity balance; resetAt is the expiry.
|
// Bonus packs: use the lifetime Capacity balance; resetAt is the expiry.
|
||||||
|
// These are one-shot credits (CycleEndTime == DeductionEndTime), so they
|
||||||
|
// never replenish — mark recurring:false so the UI shows "Expires in"
|
||||||
|
// instead of implying a monthly refill.
|
||||||
bonuses.forEach((acc, i) => {
|
bonuses.forEach((acc, i) => {
|
||||||
quotas[`Bonus Pack ${i + 1}`] = {
|
quotas[`Bonus Pack ${i + 1}`] = {
|
||||||
used: num(acc.CapacityUsedPrecise, acc.CapacityUsed),
|
used: num(acc.CapacityUsedPrecise, acc.CapacityUsed),
|
||||||
total: num(acc.CapacitySizePrecise, acc.CapacitySize),
|
total: num(acc.CapacitySizePrecise, acc.CapacitySize),
|
||||||
resetAt: parseResetTime(acc.CycleEndTime),
|
resetAt: parseResetTime(acc.CycleEndTime),
|
||||||
unlimited: false,
|
unlimited: false,
|
||||||
|
recurring: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ export default function ProviderLimitCard({
|
|||||||
percentage={percentage}
|
percentage={percentage}
|
||||||
unlimited={unlimited}
|
unlimited={unlimited}
|
||||||
resetTime={quota.resetAt}
|
resetTime={quota.resetAt}
|
||||||
|
recurring={quota.recurring !== false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -69,12 +69,17 @@ export default function QuotaProgressBar({
|
|||||||
used = 0,
|
used = 0,
|
||||||
total = 0,
|
total = 0,
|
||||||
unlimited = false,
|
unlimited = false,
|
||||||
resetTime = null
|
resetTime = null,
|
||||||
|
recurring = true,
|
||||||
}) {
|
}) {
|
||||||
const colors = getColorClasses(percentage);
|
const colors = getColorClasses(percentage);
|
||||||
const countdown = formatResetTime(resetTime);
|
const countdown = formatResetTime(resetTime);
|
||||||
const resetDisplay = formatResetTimeDisplay(resetTime);
|
const resetDisplay = formatResetTimeDisplay(resetTime);
|
||||||
|
|
||||||
|
// recurring defaults true. One-shot packs (e.g. CodeBuddy CN bonus packs)
|
||||||
|
// set recurring:false: resetTime is a hard expiry, so word it as "expires".
|
||||||
|
const resetWord = recurring ? "Reset" : "Expires";
|
||||||
|
|
||||||
// percentage is already remaining percentage (from ProviderLimitCard)
|
// percentage is already remaining percentage (from ProviderLimitCard)
|
||||||
const remaining = percentage;
|
const remaining = percentage;
|
||||||
|
|
||||||
@@ -111,7 +116,7 @@ export default function QuotaProgressBar({
|
|||||||
{countdown !== "-" && (
|
{countdown !== "-" && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span className="font-medium">Reset in {countdown}</span>
|
<span className="font-medium">{resetWord} in {countdown}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -119,7 +124,7 @@ export default function QuotaProgressBar({
|
|||||||
{/* Reset time display */}
|
{/* Reset time display */}
|
||||||
{resetDisplay && (
|
{resetDisplay && (
|
||||||
<div className="text-xs text-text-muted/70">
|
<div className="text-xs text-text-muted/70">
|
||||||
Reset at {resetDisplay}
|
{resetWord} at {resetDisplay}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -153,6 +153,11 @@ export default function QuotaTable({
|
|||||||
const colors = getColorClasses(quota.remaining);
|
const colors = getColorClasses(quota.remaining);
|
||||||
const countdown = formatResetTime(quota.resetAt);
|
const countdown = formatResetTime(quota.resetAt);
|
||||||
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
|
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
|
||||||
|
// recurring defaults true: a missing flag means the quota
|
||||||
|
// refreshes at resetAt. Bonus/one-shot packs set recurring:false
|
||||||
|
// and their resetAt is a hard expiry, so word it as "expires".
|
||||||
|
const recurring = quota.recurring !== false;
|
||||||
|
const countdownLabel = recurring ? `in ${countdown}` : `expires in ${countdown}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -197,13 +202,13 @@ export default function QuotaTable({
|
|||||||
className={`${resetPrimary} text-text-primary font-medium truncate`}
|
className={`${resetPrimary} text-text-primary font-medium truncate`}
|
||||||
title={resetDisplay || ""}
|
title={resetDisplay || ""}
|
||||||
>
|
>
|
||||||
{countdown !== "-" ? `in ${countdown}` : resetDisplay}
|
{countdown !== "-" ? countdownLabel : resetDisplay}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{countdown !== "-" && (
|
{countdown !== "-" && (
|
||||||
<div className={`${resetPrimary} text-text-primary font-medium`}>
|
<div className={`${resetPrimary} text-text-primary font-medium`}>
|
||||||
in {countdown}
|
{countdownLabel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{resetDisplay && (
|
{resetDisplay && (
|
||||||
|
|||||||
@@ -433,6 +433,24 @@ export function parseQuotaData(provider, data) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "codebuddy-cn":
|
||||||
|
// CodeBuddy CN mixes recurring refill packs ("Monthly"/"Weekly"/...)
|
||||||
|
// with one-shot bonus packs ("Bonus Pack N"). Forward `recurring`
|
||||||
|
// so the UI can show "Expires in" for bonus packs (whose resetAt is
|
||||||
|
// a hard expiry, not a refresh) instead of "Reset in".
|
||||||
|
if (data.quotas) {
|
||||||
|
Object.entries(data.quotas).forEach(([name, quota]) => {
|
||||||
|
normalizedQuotas.push({
|
||||||
|
name,
|
||||||
|
used: quota.used || 0,
|
||||||
|
total: quota.total || 0,
|
||||||
|
resetAt: quota.resetAt || null,
|
||||||
|
recurring: quota.recurring !== false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Generic fallback for unknown providers
|
// Generic fallback for unknown providers
|
||||||
if (data.quotas) {
|
if (data.quotas) {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// CodeBuddy CN mixes recurring refill packs with one-shot bonus packs.
|
||||||
|
// Bonus packs ("Bonus Pack N") must surface recurring:false so the dashboard
|
||||||
|
// shows "Expires in" instead of implying a monthly refill. The usage handler
|
||||||
|
// tags the flag and parseQuotaData must forward it.
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseQuotaData } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||||
|
|
||||||
|
describe("parseQuotaData codebuddy-cn recurring flag", () => {
|
||||||
|
it("forwards recurring:false for bonus packs and true for refill packs", () => {
|
||||||
|
const data = {
|
||||||
|
plan: "CodeBuddy CN",
|
||||||
|
quotas: {
|
||||||
|
Monthly: { used: 6.54, total: 500, resetAt: "2026-07-31T00:00:00Z", recurring: true },
|
||||||
|
"Bonus Pack 1": { used: 12, total: 100, resetAt: "2026-07-15T00:00:00Z", recurring: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const out = parseQuotaData("codebuddy-cn", data);
|
||||||
|
const byName = Object.fromEntries(out.map((q) => [q.name, q]));
|
||||||
|
|
||||||
|
expect(byName["Monthly"].recurring).toBe(true);
|
||||||
|
expect(byName["Bonus Pack 1"].recurring).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults recurring to true when the flag is absent (back-compat)", () => {
|
||||||
|
const data = { quotas: { Monthly: { used: 0, total: 100, resetAt: null } } };
|
||||||
|
const out = parseQuotaData("codebuddy-cn", data);
|
||||||
|
expect(out[0].recurring).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user