diff --git a/apps/controller/openapi.json b/apps/controller/openapi.json index 42960bfe7..dcb1060d0 100644 --- a/apps/controller/openapi.json +++ b/apps/controller/openapi.json @@ -3208,6 +3208,11 @@ "type": "integer", "minimum": 0 }, + "giftBalance": { + "type": "integer", + "minimum": 0, + "default": 0 + }, "totalRecharged": { "type": "integer", "minimum": 0 @@ -3215,13 +3220,21 @@ "totalConsumed": { "type": "integer", "minimum": 0 + }, + "syncedAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" } }, "default": null, "required": [ "totalBalance", "totalRecharged", - "totalConsumed" + "totalConsumed", + "syncedAt", + "updatedAt" ] }, "autoFallbackTriggered": { @@ -3514,6 +3527,11 @@ "type": "integer", "minimum": 0 }, + "giftBalance": { + "type": "integer", + "minimum": 0, + "default": 0 + }, "totalRecharged": { "type": "integer", "minimum": 0 @@ -3521,13 +3539,21 @@ "totalConsumed": { "type": "integer", "minimum": 0 + }, + "syncedAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" } }, "default": null, "required": [ "totalBalance", "totalRecharged", - "totalConsumed" + "totalConsumed", + "syncedAt", + "updatedAt" ] }, "autoFallbackTriggered": { @@ -3746,6 +3772,11 @@ "type": "integer", "minimum": 0 }, + "giftBalance": { + "type": "integer", + "minimum": 0, + "default": 0 + }, "totalRecharged": { "type": "integer", "minimum": 0 @@ -3753,13 +3784,21 @@ "totalConsumed": { "type": "integer", "minimum": 0 + }, + "syncedAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" } }, "default": null, "required": [ "totalBalance", "totalRecharged", - "totalConsumed" + "totalConsumed", + "syncedAt", + "updatedAt" ] }, "autoFallbackTriggered": { diff --git a/apps/controller/src/services/cloud-reward-service.ts b/apps/controller/src/services/cloud-reward-service.ts index 297d31aa2..cc82783b1 100644 --- a/apps/controller/src/services/cloud-reward-service.ts +++ b/apps/controller/src/services/cloud-reward-service.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { type DesktopRewardClaimProof, + creditSummaryResponseSchema, rewardRepeatModeSchema, rewardShareModeSchema, } from "@nexu/shared"; @@ -62,6 +63,7 @@ const cloudErrorResponseSchema = z.object({ export type RewardStatusResponse = z.infer; export type RewardClaimResponse = z.infer; +export type CreditSummaryResponse = z.infer; export type CloudRewardErrorReason = | "auth_failed" @@ -74,6 +76,7 @@ export type CloudRewardResult = export type CloudRewardService = { getRewardsStatus(): Promise>; + getCreditsSummary(): Promise>; claimReward( taskId: string, proof?: DesktopRewardClaimProof, @@ -158,6 +161,48 @@ export function createCloudRewardService( } }, + async getCreditsSummary() { + try { + const res = await fetchWithAuth("/api/v1/credits/summary"); + if (res.status === 401 || res.status === 403) { + return { + ok: false, + reason: "auth_failed", + message: await readCloudErrorMessage(res), + }; + } + if (!res.ok) { + logger.warn( + { status: res.status, url: `${cloudUrl}/api/v1/credits/summary` }, + "cloud_credits_summary_http_error", + ); + return { ok: false, reason: "network_error" }; + } + const data: unknown = await res.json(); + const parsed = creditSummaryResponseSchema.safeParse(data); + if (!parsed.success) { + logger.warn( + { + issues: parsed.error.issues.slice(0, 5), + url: `${cloudUrl}/api/v1/credits/summary`, + }, + "cloud_credits_summary_parse_error", + ); + return { ok: false, reason: "parse_error" }; + } + return { ok: true, data: parsed.data }; + } catch (error: unknown) { + logger.warn( + { + error: error instanceof Error ? error.message : String(error), + url: `${cloudUrl}/api/v1/credits/summary`, + }, + "cloud_credits_summary_network_error", + ); + return { ok: false, reason: "network_error" }; + } + }, + async claimReward(taskId, proof) { try { const res = await fetchWithAuth("/api/v1/rewards/claim", { diff --git a/apps/controller/src/store/nexu-config-store.ts b/apps/controller/src/store/nexu-config-store.ts index 9d83b09f1..407a798ac 100644 --- a/apps/controller/src/store/nexu-config-store.ts +++ b/apps/controller/src/store/nexu-config-store.ts @@ -40,6 +40,7 @@ import { logger } from "../lib/logger.js"; import { resolveManagedCloudModel } from "../lib/managed-models.js"; import { proxyFetch } from "../lib/proxy-fetch.js"; import { + type CreditSummaryResponse, type RewardStatusResponse, createCloudRewardService, } from "../services/cloud-reward-service.js"; @@ -93,6 +94,8 @@ type DesktopRewardClaimResponse = z.infer< typeof claimDesktopRewardResponseSchema >; +const CLOUD_REWARD_CREDITS_SUMMARY_BEST_EFFORT_MS = 1500; + const defaultCloudProfile: CloudProfileEntry = { name: "Default", cloudUrl: "https://nexu.io", @@ -492,6 +495,7 @@ function convertCloudStatusToDesktop( activeModelId: string | null; activeManagedModel: { provider?: string } | null | undefined; }, + creditsBalance: CreditSummaryResponse["balance"] | null, ): DesktopRewardsStatus { const { cloudConnected, activeModelId, activeManagedModel } = viewer; const tasks = cloudStatus.tasks.flatMap((task) => { @@ -532,13 +536,18 @@ function convertCloudStatusToDesktop( totalCount: tasks.length, }, tasks, - cloudBalance: cloudStatus.cloudBalance - ? { - totalBalance: cloudStatus.cloudBalance.totalBalance, - totalRecharged: cloudStatus.cloudBalance.totalRecharged, - totalConsumed: cloudStatus.cloudBalance.totalConsumed, - } - : null, + cloudBalance: + creditsBalance ?? + (cloudStatus.cloudBalance + ? { + totalBalance: cloudStatus.cloudBalance.totalBalance, + giftBalance: 0, + totalRecharged: cloudStatus.cloudBalance.totalRecharged, + totalConsumed: cloudStatus.cloudBalance.totalConsumed, + syncedAt: cloudStatus.cloudBalance.syncedAt, + updatedAt: cloudStatus.cloudBalance.updatedAt, + } + : null), }; } @@ -730,6 +739,24 @@ export class NexuConfigStore { }); } + private async readBestEffortCreditsBalance( + creditsSummaryPromise: Promise< + | { ok: true; data: CreditSummaryResponse } + | { ok: false; reason: string; message?: string } + >, + ): Promise { + const result = await Promise.race([ + creditsSummaryPromise, + this.sleep(CLOUD_REWARD_CREDITS_SUMMARY_BEST_EFFORT_MS).then(() => null), + ]); + + if (!result || !result.ok) { + return null; + } + + return result.data.balance; + } + private isCurrentPollingSignal(signal: AbortSignal): boolean { // The polling loop may still be processing a response when a newer // connectDesktopCloud() call has already aborted it and installed a fresh @@ -1712,7 +1739,7 @@ export class NexuConfigStore { activeModelId, cloud.models ?? [], ); - + let creditsBalance: CreditSummaryResponse["balance"] | null = null; if (cloud.connected && cloud.apiKey) { const { activeProfile } = await this.readConfiguredDesktopCloudProfile(config); @@ -1721,15 +1748,34 @@ export class NexuConfigStore { cloudUrl, apiKey: cloud.apiKey, }); + const creditsSummaryPromise = service.getCreditsSummary(); const cloudResult = await service.getRewardsStatus(); if (cloudResult.ok) { - const cloudStatus = cloudResult.data; - return convertCloudStatusToDesktop(cloudStatus, { - cloudConnected: true, - activeModelId, - activeManagedModel, - }); + const creditsBalance = await this.readBestEffortCreditsBalance( + creditsSummaryPromise, + ); + return convertCloudStatusToDesktop( + cloudResult.data, + { + cloudConnected: true, + activeModelId, + activeManagedModel, + }, + creditsBalance, + ); + } + + const creditsSummaryResult = await creditsSummaryPromise; + creditsBalance = creditsSummaryResult.ok + ? creditsSummaryResult.data.balance + : null; + + if (!creditsSummaryResult.ok) { + logger.warn( + { reason: creditsSummaryResult.reason }, + "desktop_credits_summary_cloud_fallback", + ); } if (cloudResult.reason === "auth_failed") { @@ -1746,7 +1792,7 @@ export class NexuConfigStore { }, progress: { claimedCount: 0, totalCount: 0, earnedCredits: 0 }, tasks: [], - cloudBalance: null, + cloudBalance: creditsBalance, }; } @@ -1769,7 +1815,7 @@ export class NexuConfigStore { }, progress: { claimedCount: 0, totalCount: 0, earnedCredits: 0 }, tasks: [], - cloudBalance: null, + cloudBalance: creditsBalance, }; } @@ -1813,15 +1859,22 @@ export class NexuConfigStore { activeModelId2, cloud2.models ?? [], ); + const creditsBalance = await this.readBestEffortCreditsBalance( + service.getCreditsSummary(), + ); return { ok: claimData.ok, alreadyClaimed: claimData.alreadyClaimed, - status: convertCloudStatusToDesktop(claimData.status, { - cloudConnected: true, - activeModelId: activeModelId2, - activeManagedModel: activeManagedModel2, - }), + status: convertCloudStatusToDesktop( + claimData.status, + { + cloudConnected: true, + activeModelId: activeModelId2, + activeManagedModel: activeManagedModel2, + }, + creditsBalance, + ), }; } diff --git a/apps/controller/tests/cloud-reward-service.test.ts b/apps/controller/tests/cloud-reward-service.test.ts index 594d0764f..7edd4856a 100644 --- a/apps/controller/tests/cloud-reward-service.test.ts +++ b/apps/controller/tests/cloud-reward-service.test.ts @@ -38,6 +38,24 @@ const mockRewardStatusResponse = { }, }; +const mockCreditsSummaryResponse = { + appUserId: "user-1", + balance: { + totalBalance: 500, + giftBalance: 125, + totalRecharged: 600, + totalConsumed: 100, + syncedAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + usageSummary: { + totalEntries: 1, + totalDueCredits: 100, + totalChargedCredits: 100, + totalCostUsd: "0.00", + }, +}; + const mockClaimResponse = { ok: true, alreadyClaimed: false, @@ -265,6 +283,53 @@ describe("createCloudRewardService", () => { }); }); + describe("getCreditsSummary", () => { + it("returns ok:true with parsed summary on success", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify(mockCreditsSummaryResponse), { + status: 200, + }), + ), + ); + + const service = createCloudRewardService({ + cloudUrl: CLOUD_URL, + apiKey: API_KEY, + }); + const result = await service.getCreditsSummary(); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.data.balance.totalBalance).toBe(500); + expect(result.data.balance.giftBalance).toBe(125); + }); + + it("returns ok:false reason:parse_error when summary body does not match schema", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ unexpected: "shape" }), { + status: 200, + }), + ), + ); + + const service = createCloudRewardService({ + cloudUrl: CLOUD_URL, + apiKey: API_KEY, + }); + const result = await service.getCreditsSummary(); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.reason).toBe("parse_error"); + }); + }); + describe("claimReward", () => { it("returns ok:true with claim result on success", async () => { vi.stubGlobal( diff --git a/apps/controller/tests/desktop-rewards-routes.test.ts b/apps/controller/tests/desktop-rewards-routes.test.ts index 5476e071a..d45cfbfaf 100644 --- a/apps/controller/tests/desktop-rewards-routes.test.ts +++ b/apps/controller/tests/desktop-rewards-routes.test.ts @@ -21,6 +21,7 @@ describe("registerDesktopRewardsRoutes", () => { tasks: [], cloudBalance: { totalBalance: 0, + giftBalance: 0, totalRecharged: 900, totalConsumed: 900, }, @@ -71,6 +72,7 @@ describe("registerDesktopRewardsRoutes", () => { tasks: [], cloudBalance: { totalBalance: 1337, + giftBalance: 0, totalRecharged: 1337, totalConsumed: 0, }, diff --git a/apps/controller/tests/nexu-config-store.test.ts b/apps/controller/tests/nexu-config-store.test.ts index 2193b892b..20bb7a427 100644 --- a/apps/controller/tests/nexu-config-store.test.ts +++ b/apps/controller/tests/nexu-config-store.test.ts @@ -751,12 +751,30 @@ describe("NexuConfigStore", () => { progress: { claimedCount: 0, totalCount: 0, earnedCredits: 0 }, cloudBalance: { totalBalance: 4200, + giftBalance: 84, totalRecharged: 4200, totalConsumed: 0, syncedAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", }, }; + const creditsSummaryResponse = { + appUserId: "user-1", + balance: { + totalBalance: 4200, + giftBalance: 84, + totalRecharged: 4200, + totalConsumed: 0, + syncedAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + usageSummary: { + totalEntries: 0, + totalDueCredits: 0, + totalChargedCredits: 0, + totalCostUsd: "0.00", + }, + }; let fetchCalls = 0; let capturedBody: string | null = null; @@ -769,24 +787,35 @@ describe("NexuConfigStore", () => { return new Response(null, { status: 204 }); } - return new Response(JSON.stringify(statusResponse), { status: 200 }); + if (fetchCalls === 2) { + return new Response(JSON.stringify(creditsSummaryResponse), { + status: 200, + }); + } + + if (fetchCalls === 3) { + return new Response(JSON.stringify(statusResponse), { status: 200 }); + } + + throw new Error(`unexpected fetch call ${fetchCalls}`); }), ); try { const status = await store.setDesktopRewardBalance(4200); - expect(fetchCalls).toBe(2); + expect(fetchCalls).toBe(3); expect(JSON.parse(capturedBody ?? "{}")).toEqual({ targetBalance: 4200, idempotencyKey: expect.stringContaining("desktop-set-balance-"), }); expect(status.cloudBalance?.totalBalance).toBe(4200); + expect(status.cloudBalance?.giftBalance).toBe(84); } finally { vi.unstubAllGlobals(); } }); - it("getDesktopRewardsStatus preserves cloud balance when cloud returns unknown task ids", async () => { + it("getDesktopRewardsStatus returns rewards status without waiting on a slow credits summary", async () => { await mkdir(path.join(rootDir, ".nexu"), { recursive: true }); await writeFile( path.join(rootDir, ".nexu", "config.json"), @@ -849,11 +878,37 @@ describe("NexuConfigStore", () => { const store = new NexuConfigStore(env); + let fetchCalls = 0; vi.stubGlobal( "fetch", - vi.fn( - async () => - new Response( + vi.fn(async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return new Response( + JSON.stringify({ + appUserId: "user-1", + balance: { + totalBalance: 1, + giftBalance: 9, + totalRecharged: 1210, + totalConsumed: 1209, + syncedAt: "2026-04-07T09:36:51.342Z", + updatedAt: "2026-04-07T09:36:51.342Z", + }, + usageSummary: { + totalEntries: 0, + totalDueCredits: 0, + totalChargedCredits: 0, + totalCostUsd: "0.00", + }, + }), + { status: 200 }, + ); + } + + if (fetchCalls === 2) { + return new Response( JSON.stringify({ tasks: [ { @@ -897,13 +952,20 @@ describe("NexuConfigStore", () => { }, }), { status: 200 }, - ), - ), + ); + } + + throw new Error(`unexpected fetch call ${fetchCalls}`); + }), ); try { + const startedAt = Date.now(); const status = await store.getDesktopRewardsStatus(); + expect(Date.now() - startedAt).toBeLessThan(600); + expect(fetchCalls).toBe(2); expect(status.cloudBalance?.totalBalance).toBe(1); + expect(status.cloudBalance?.giftBalance).toBe(0); expect(status.tasks).toHaveLength(1); expect(status.tasks[0]?.id).toBe("daily_checkin"); expect(status.progress.claimedCount).toBe(1); @@ -913,6 +975,213 @@ describe("NexuConfigStore", () => { } }); + it("getDesktopRewardsStatus keeps gift balance when credits summary returns quickly", async () => { + await mkdir(path.join(rootDir, ".nexu"), { recursive: true }); + await writeFile( + path.join(rootDir, ".nexu", "config.json"), + JSON.stringify( + { + version: 1, + runtime: { + defaultModelId: "gemini-3-flash-preview", + }, + desktop: { + cloud: { + connected: true, + polling: false, + userName: "Cloud User", + userEmail: "user@nexu.io", + connectedAt: "2026-04-01T00:00:00.000Z", + linkUrl: "http://localhost:8080", + apiKey: "valid-key", + models: [], + }, + activeCloudProfileName: "Local", + cloudSessions: { + Local: { + connected: true, + polling: false, + userName: "Cloud User", + userEmail: "user@nexu.io", + connectedAt: "2026-04-01T00:00:00.000Z", + linkUrl: "http://localhost:8080", + apiKey: "valid-key", + models: [], + }, + }, + }, + secrets: {}, + }, + null, + 2, + ), + "utf8", + ); + await writeFile( + path.join(rootDir, ".nexu", "cloud-profiles.json"), + JSON.stringify( + { + schemaVersion: 1, + profiles: [ + { + name: "Local", + cloudUrl: "http://localhost:5173", + linkUrl: "http://localhost:8080", + }, + ], + }, + null, + 2, + ), + "utf8", + ); + + const store = new NexuConfigStore(env); + + let fetchCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + return new Response( + JSON.stringify({ + appUserId: "user-1", + balance: { + totalBalance: 1, + giftBalance: 9, + totalRecharged: 1210, + totalConsumed: 1209, + syncedAt: "2026-04-07T09:36:51.342Z", + updatedAt: "2026-04-07T09:36:51.342Z", + }, + usageSummary: { + totalEntries: 0, + totalDueCredits: 0, + totalChargedCredits: 0, + totalCostUsd: "0.00", + }, + }), + { status: 200 }, + ); + } + + if (fetchCalls === 2) { + return new Response( + JSON.stringify({ + tasks: [], + progress: { + claimedCount: 0, + totalCount: 0, + earnedCredits: 0, + }, + cloudBalance: { + totalBalance: 1, + totalRecharged: 1210, + totalConsumed: 1209, + syncedAt: "2026-04-07T09:36:51.342Z", + updatedAt: "2026-04-07T09:36:51.342Z", + }, + }), + { status: 200 }, + ); + } + + throw new Error(`unexpected fetch call ${fetchCalls}`); + }), + ); + + try { + const status = await store.getDesktopRewardsStatus(); + expect(fetchCalls).toBe(2); + expect(status.cloudBalance?.totalBalance).toBe(1); + expect(status.cloudBalance?.giftBalance).toBe(9); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("getDesktopRewardsStatus keeps the credits summary balance when rewards status fails", async () => { + await mkdir(path.join(rootDir, ".nexu"), { recursive: true }); + await writeFile( + path.join(rootDir, ".nexu", "config.json"), + JSON.stringify( + { + version: 1, + desktop: { + cloud: { + connected: true, + polling: false, + userName: "Cloud User", + userEmail: "user@nexu.io", + connectedAt: "2026-04-01T00:00:00.000Z", + linkUrl: "http://localhost:8080", + apiKey: "valid-key", + models: [], + }, + }, + secrets: {}, + }, + null, + 2, + ), + "utf8", + ); + + const store = new NexuConfigStore(env); + + let fetchCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + return new Response( + JSON.stringify({ + appUserId: "user-1", + balance: { + totalBalance: 1, + giftBalance: 9, + totalRecharged: 1210, + totalConsumed: 1209, + syncedAt: "2026-04-07T09:36:51.342Z", + updatedAt: "2026-04-07T09:36:51.342Z", + }, + usageSummary: { + totalEntries: 0, + totalDueCredits: 0, + totalChargedCredits: 0, + totalCostUsd: "0.00", + }, + }), + { status: 200 }, + ); + } + + if (fetchCalls === 2) { + return new Response(null, { status: 500 }); + } + + throw new Error(`unexpected fetch call ${fetchCalls}`); + }), + ); + + try { + const status = await store.getDesktopRewardsStatus(); + expect(fetchCalls).toBe(2); + expect(status.progress).toEqual({ + claimedCount: 0, + totalCount: 0, + earnedCredits: 0, + }); + expect(status.tasks).toHaveLength(0); + expect(status.cloudBalance?.totalBalance).toBe(1); + expect(status.cloudBalance?.giftBalance).toBe(9); + } finally { + vi.unstubAllGlobals(); + } + }); + it("getDesktopRewardsStatus returns empty fallback when cloud is not connected", async () => { const store = new NexuConfigStore(env); @@ -1068,7 +1337,7 @@ describe("NexuConfigStore", () => { } }); - it("claimDesktopReward uses status from claim response without extra fetch", async () => { + it("claimDesktopReward returns claim status without waiting on credits summary", async () => { await mkdir(path.join(rootDir, ".nexu"), { recursive: true }); const mockTask = { @@ -1094,7 +1363,6 @@ describe("NexuConfigStore", () => { cloudBalance: null, }, }; - await writeFile( path.join(rootDir, ".nexu", "config.json"), JSON.stringify( @@ -1128,22 +1396,33 @@ describe("NexuConfigStore", () => { "fetch", vi.fn(async (_input, init) => { fetchCallCount += 1; - claimBody = init?.body ?? null; - return new Response(JSON.stringify(claimResponse), { status: 200 }); + if (fetchCallCount === 1) { + claimBody = init?.body ?? null; + return new Response(JSON.stringify(claimResponse), { + status: 200, + }); + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + return new Response(JSON.stringify(claimResponse), { + status: 200, + }); }), ); try { + const startedAt = Date.now(); const result = await store.claimDesktopReward("daily_checkin", { url: "https://x.com/nexu_io/status/1900000000000000000", }); + expect(Date.now() - startedAt).toBeLessThan(600); expect(result.ok).toBe(true); expect(result.alreadyClaimed).toBe(false); expect(result.status.tasks).toHaveLength(1); expect(result.status.tasks[0]?.isClaimed).toBe(true); expect(result.status.progress.claimedCount).toBe(1); - // Only one fetch call for claim — no extra status fetch - expect(fetchCallCount).toBe(1); + expect(result.status.cloudBalance).toBeNull(); + expect(fetchCallCount).toBe(2); expect(claimBody).toBe( JSON.stringify({ taskId: "daily_checkin", @@ -1154,4 +1433,100 @@ describe("NexuConfigStore", () => { vi.unstubAllGlobals(); } }); + + it("claimDesktopReward keeps gift balance when credits summary returns quickly", async () => { + await mkdir(path.join(rootDir, ".nexu"), { recursive: true }); + + const claimResponse = { + ok: true, + alreadyClaimed: false, + status: { + tasks: [], + progress: { claimedCount: 1, totalCount: 1, earnedCredits: 100 }, + cloudBalance: { + totalBalance: 10, + totalRecharged: 10, + totalConsumed: 0, + syncedAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + }, + }; + + await writeFile( + path.join(rootDir, ".nexu", "config.json"), + JSON.stringify( + { + version: 1, + desktop: { + cloud: { + connected: true, + polling: false, + userName: "Cloud User", + userEmail: "user@nexu.io", + connectedAt: "2026-04-01T00:00:00.000Z", + linkUrl: "https://link.nexu.io", + apiKey: "valid-key", + models: [], + }, + }, + secrets: {}, + }, + null, + 2, + ), + "utf8", + ); + + const store = new NexuConfigStore(env); + + let fetchCallCount = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (_input, init) => { + fetchCallCount += 1; + if (fetchCallCount === 1) { + expect(init?.body).toBe( + JSON.stringify({ + taskId: "daily_checkin", + proofUrl: undefined, + }), + ); + return new Response(JSON.stringify(claimResponse), { + status: 200, + }); + } + + return new Response( + JSON.stringify({ + appUserId: "user-1", + balance: { + totalBalance: 10, + giftBalance: 4, + totalRecharged: 10, + totalConsumed: 0, + syncedAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + usageSummary: { + totalEntries: 0, + totalDueCredits: 0, + totalChargedCredits: 0, + totalCostUsd: "0.00", + }, + }), + { status: 200 }, + ); + }), + ); + + try { + const result = await store.claimDesktopReward("daily_checkin"); + expect(fetchCallCount).toBe(2); + expect(result.status.cloudBalance?.totalBalance).toBe(10); + expect(result.status.cloudBalance?.giftBalance).toBe(4); + } finally { + vi.unstubAllGlobals(); + } + }); }); diff --git a/apps/web/lib/api/types.gen.ts b/apps/web/lib/api/types.gen.ts index 8feb59081..ea4868bd9 100644 --- a/apps/web/lib/api/types.gen.ts +++ b/apps/web/lib/api/types.gen.ts @@ -1116,8 +1116,11 @@ export type GetApiInternalDesktopRewardsResponses = { }>; cloudBalance?: { totalBalance: number; + giftBalance?: number; totalRecharged: number; totalConsumed: number; + syncedAt: string; + updatedAt: string; }; autoFallbackTriggered?: boolean; }; @@ -1217,8 +1220,11 @@ export type PostApiInternalDesktopRewardsClaimResponses = { }>; cloudBalance?: { totalBalance: number; + giftBalance?: number; totalRecharged: number; totalConsumed: number; + syncedAt: string; + updatedAt: string; }; autoFallbackTriggered?: boolean; }; @@ -1279,8 +1285,11 @@ export type PostApiInternalDesktopRewardsSetBalanceResponses = { }>; cloudBalance?: { totalBalance: number; + giftBalance?: number; totalRecharged: number; totalConsumed: number; + syncedAt: string; + updatedAt: string; }; autoFallbackTriggered?: boolean; }; diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 14c706176..76c647ce3 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -163,9 +163,9 @@ const en = { "layout.sidebar.balancePopup.recharged": "Plan credits (cumulative)", "layout.sidebar.balancePopup.rechargedTooltip": "Total credits added from your subscription plan", - "layout.sidebar.balancePopup.earned": "Reward credits", + "layout.sidebar.balancePopup.earned": "Gift credit balance", "layout.sidebar.balancePopup.earnedTooltip": - "Earned from signup rewards, completed tasks, and activities. Consumption order: plan credits → credit packs → reward credits.", + "Credits granted from rewards and promotions. Consumption order: plan credits → credit packs → gift credits.", "layout.sidebar.balancePopup.consumed": "Consumed", "layout.sidebar.balancePopup.viewDetail": "View details", "layout.mobile.settings": "Settings", diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index c94dfa84f..0f607e63f 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -163,9 +163,9 @@ const zhCN = { "layout.sidebar.balancePopup.recharged": "套餐积分(累计)", "layout.sidebar.balancePopup.rechargedTooltip": "从订阅套餐中累计获得的积分总量", - "layout.sidebar.balancePopup.earned": "赠送积分", + "layout.sidebar.balancePopup.earned": "赠送积分余额", "layout.sidebar.balancePopup.earnedTooltip": - "来自注册奖励、完成任务等活动。消耗顺序:套餐积分 → 积分包 → 赠送积分。", + "来自奖励和活动赠送的积分。消耗顺序:套餐积分 → 积分包 → 赠送积分。", "layout.sidebar.balancePopup.consumed": "累计消耗", "layout.sidebar.balancePopup.viewDetail": "查看使用情况", "layout.empty.title": "暂无对话", diff --git a/apps/web/src/layouts/workspace-layout.tsx b/apps/web/src/layouts/workspace-layout.tsx index 844faf4c5..700135c59 100644 --- a/apps/web/src/layouts/workspace-layout.tsx +++ b/apps/web/src/layouts/workspace-layout.tsx @@ -213,6 +213,16 @@ function resolveCloudUsageUrl(cloudUrl?: string | null): string { } } +type RewardsBalanceSource = { + cloudBalance: { giftBalance: number } | null; +}; + +export function getWorkspaceGiftBalance( + rewardsStatus: RewardsBalanceSource, +): number | null { + return rewardsStatus.cloudBalance?.giftBalance ?? null; +} + const GitHubIcon = () => ( GitHub @@ -585,18 +595,23 @@ function WorkspaceLayoutInner() { cloudConnected && !rewardsStatus.cloudBalance && (rewardsStatusLoading || !rewardsStatusResolved); + const rewardsBalanceUnavailable = + cloudConnected && + !rewardsBalancePending && + rewardsStatus.cloudBalance === null; const canOpenBalancePopup = cloudConnected || rewardsStatus.cloudBalance !== null; const rewardBalanceValue = rewardsStatus.cloudBalance ? `${rewardsStatus.cloudBalance.totalBalance} ${t("layout.sidebar.balanceUnit")}` : cloudConnected - ? rewardsBalancePending - ? t("layout.sidebar.balancePlaceholder") - : `0 ${t("layout.sidebar.balanceUnit")}` + ? t("layout.sidebar.balancePlaceholder") : t("layout.sidebar.balancePlaceholder"); const rewardBalancePopupValue = rewardsStatus.cloudBalance ? String(rewardsStatus.cloudBalance.totalBalance) - : rewardBalanceValue; + : t("layout.sidebar.balancePlaceholder"); + const rewardGiftBalanceValue = rewardsBalanceUnavailable + ? t("layout.sidebar.balancePlaceholder") + : String(getWorkspaceGiftBalance(rewardsStatus) ?? 0); const shouldShowRewardsBanner = cloudConnected && rewardsStatus.progress.totalCount > 0 && @@ -1061,7 +1076,7 @@ function WorkspaceLayoutInner() { - {rewardsStatus.progress.earnedCredits} + {rewardGiftBalanceValue} diff --git a/apps/web/tests/workspace-layout.test.tsx b/apps/web/tests/workspace-layout.test.tsx index e9bd16ddb..8ee30961f 100644 --- a/apps/web/tests/workspace-layout.test.tsx +++ b/apps/web/tests/workspace-layout.test.tsx @@ -2,7 +2,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { WorkspaceLayout } from "../src/layouts/workspace-layout"; +import { + WorkspaceLayout, + getWorkspaceGiftBalance, +} from "../src/layouts/workspace-layout"; vi.mock("@/lib/api", () => ({})); @@ -110,6 +113,7 @@ function renderWorkspaceLayout( }; cloudBalance: { totalBalance: number; + giftBalance?: number; totalRecharged: number; totalConsumed: number; } | null; @@ -196,6 +200,13 @@ describe("WorkspaceLayout", () => { installBrowserStubs(); }); + it("uses the credits summary balance as the gift balance source", () => { + expect(getWorkspaceGiftBalance({ cloudBalance: null })).toBeNull(); + expect(getWorkspaceGiftBalance({ cloudBalance: { giftBalance: 42 } })).toBe( + 42, + ); + }); + it("renders structured sidebar session rows for the workspace shell", () => { const markup = renderWorkspaceLayout(); @@ -236,6 +247,7 @@ describe("WorkspaceLayout", () => { }, cloudBalance: { totalBalance: 200, + giftBalance: 42, totalRecharged: 900, totalConsumed: 700, }, @@ -418,7 +430,7 @@ describe("WorkspaceLayout", () => { expect(markup).not.toContain('data-budget-banner-status="warning"'); }); - it("renders the logged-in rewards card with a separate balance entry", () => { + it("renders the logged-in rewards card with the credit balance entry", () => { const markup = renderWorkspaceLayout( "/workspace/sessions/sess-1", { @@ -435,6 +447,7 @@ describe("WorkspaceLayout", () => { }, cloudBalance: { totalBalance: 200, + giftBalance: 42, totalRecharged: 900, totalConsumed: 700, }, @@ -450,6 +463,7 @@ describe("WorkspaceLayout", () => { expect(markup).toContain('data-sidebar-rewards-balance="true"'); expect(markup).toContain("layout.sidebar.balanceLabel"); expect(markup).toContain("200 layout.sidebar.balanceUnit"); + expect(markup).toContain("42"); expect(markup).not.toContain("layout.sidebar.loginTitle"); }); @@ -483,7 +497,6 @@ describe("WorkspaceLayout", () => { expect(markup).toContain('data-sidebar-growth-card="rewards"'); expect(markup).toContain("layout.sidebar.balanceLabel"); }); - it("renders zero balance when connected but cloud balance is null", () => { const markup = renderWorkspaceLayout( "/workspace/sessions/sess-1", diff --git a/packages/shared/src/schemas/credit.ts b/packages/shared/src/schemas/credit.ts index 3fb9f8d97..7256ae9eb 100644 --- a/packages/shared/src/schemas/credit.ts +++ b/packages/shared/src/schemas/credit.ts @@ -21,6 +21,7 @@ export type CreditUsageSummary = z.infer; export const creditBalanceSummarySchema = z.object({ totalBalance: z.number().int().nonnegative(), + giftBalance: z.number().int().nonnegative().optional().default(0), totalRecharged: z.number().int().nonnegative(), totalConsumed: z.number().int().nonnegative(), syncedAt: z.string(), diff --git a/packages/shared/src/schemas/rewards.ts b/packages/shared/src/schemas/rewards.ts index 9057aa026..9e0f62ac9 100644 --- a/packages/shared/src/schemas/rewards.ts +++ b/packages/shared/src/schemas/rewards.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { creditBalanceSummarySchema } from "./credit.js"; export const rewardGroupSchema = z.enum(["daily", "opensource", "social"]); export const rewardShareModeSchema = z.enum(["link", "tweet", "image"]); @@ -173,13 +174,7 @@ export const desktopRewardsViewerSchema = z.object({ usingManagedModel: z.boolean(), }); -export const cloudCreditBalanceSchema = z - .object({ - totalBalance: z.number().int().nonnegative(), - totalRecharged: z.number().int().nonnegative(), - totalConsumed: z.number().int().nonnegative(), - }) - .nullable(); +export const cloudCreditBalanceSchema = creditBalanceSummarySchema.nullable(); export const desktopRewardsStatusSchema = z.object({ viewer: desktopRewardsViewerSchema,