← back to Kickbacks Ai Upstream
src/activation/earningsRefresh.ts
164 lines
import * as vscode from "vscode";
import type { AuthClient } from "../auth/client";
import type { EarningsClient } from "../earnings/client";
import type { SessionState } from "../sessionState";
import type { SbState } from "../statusbar";
import type { EarningCap } from "../earnings/client";
import { servingGateSnapshot } from "../servingGate";
import { EARNINGS_STALE_MS, type FleetSignals } from "../fleetSignals";
export interface EarningsRefreshResult {
showActive: () => Promise<void>;
scheduleEarningsRefresh: (delayMs?: number) => void;
}
export function setupEarningsRefresh(
auth: AuthClient,
earningsClient: EarningsClient,
session: SessionState,
statusBar: { set: (s: SbState) => void },
ccVersion: string,
ctx: vscode.ExtensionContext,
// Arbiter: true while the status-bar ad owns the item (set by statusBarAd).
// When an ad is showing, showActive keeps the earnings data fresh but does
// NOT paint over the ad — statusBarAd repaints the earnings label itself
// when the ad reverts. Defaults to "never showing" for callers that don't
// wire the arbiter (tests, other surfaces).
isAdShowing: () => boolean = () => false,
// The red cap-warning pill (a SEPARATE status-bar item). show() when a cap
// is hit, hide() in every not-earning safety state so it never lingers next
// to a kill/offline/sign-in bar. Defaults to a no-op for callers that don't
// wire it (tests, other surfaces) — back-compat.
capWarning: { show: (c: EarningCap) => void; hide: () => void } =
{ show: () => {}, hide: () => {} },
// Fleet-signal store (fleet-chattiness fix): when the backend piggybacks
// `balances` (incl. the cap) on portfolio/metrics responses, showActive
// paints from the store and the standalone GET /v1/earnings stands down.
// null/old-backend (no `cap` key on the carrier) keeps today's behavior.
signals: FleetSignals | null = null,
): EarningsRefreshResult {
let lastUsd: string | undefined;
let lastToday: string | undefined;
let pendingRefreshTimer: NodeJS.Timeout | null = null;
const scheduleEarningsRefresh = (delayMs = 2500): void => {
if (pendingRefreshTimer) clearTimeout(pendingRefreshTimer);
pendingRefreshTimer = setTimeout(() => {
pendingRefreshTimer = null;
void showActive();
}, delayMs);
try { pendingRefreshTimer.unref?.(); } catch { /* never disrupt */ }
};
const showActive = async (): Promise<void> => {
// Serving gate (wave 2, audit #4): pre-fix this 30s repaint clobbered
// the killed/offline/Off bar back to green "active ($… today)" within
// 30s of a kill or a deliberate "Disable Kickbacks". Safety/truth
// states always win (the Wave-1 status-bar contract): paint the gated
// state and never the earning label while the gate says no-serve.
const gate = servingGateSnapshot();
if (gate.kill === "confirmed") {
capWarning.hide();
statusBar.set({ kind: "killed" });
return;
}
if (gate.kill === "offline") {
capWarning.hide();
statusBar.set({ kind: "offline" });
return;
}
if (!auth.accessToken()) {
capWarning.hide();
statusBar.set({ kind: "signed-out" });
session.set({ signedIn: false });
return;
}
if (!gate.enabled || gate.suspended) {
// User-disabled (or crash-canary suspended): the red "Kickbacks: Off —
// click to re-enable" bar, the existing debug-OFF SbState.
capWarning.hide();
statusBar.set({ kind: "debug", on: false });
return;
}
// Fleet-signal fast path: fresh piggybacked balances from an AUTHED 2xx
// carrier replace the network fetch entirely. `capCapable` (the carrier
// had the `cap` KEY) is the new-backend marker — without it the
// standalone fetch below must keep running, it's the only cap source.
// The carrier being an authed 200 also vouches for auth health.
const snap = signals?.earningsSnapshot();
if (snap && snap.capCapable
&& signals?.earningsFreshWithin(EARNINGS_STALE_MS)) {
lastUsd = snap.lifetimeUsd; lastToday = snap.todayUsd;
session.set({ signedIn: true, authHealthy: "ok" });
if (snap.cap) capWarning.show(snap.cap);
else capWarning.hide();
if (isAdShowing()) return;
statusBar.set({ kind: "active", version: ccVersion,
usd: lastUsd, usdToday: lastToday });
return;
}
const r = await earningsClient.fetchDetailed();
// Token died during the await (sign-out / failed rotation mid-fetch):
// paint signed-out instead of a stale green "active" bar (audit #34).
if (!auth.accessToken()) {
capWarning.hide();
statusBar.set({ kind: "signed-out" });
session.set({ signedIn: false });
return;
}
if (r.outcome === "ok") {
lastUsd = r.earnings.lifetimeUsd; lastToday = r.earnings.todayUsd;
session.set({ signedIn: true, authHealthy: "ok" });
// Drive the red cap pill from the authoritative cap state. Done BEFORE
// the isAdShowing() guard below so the cap warning shows even while a
// status-bar ad owns the main earnings item (separate item).
if (r.earnings.cap) capWarning.show(r.earnings.cap);
else capWarning.hide();
} else if (r.outcome === "401") {
// Only a REAL backend 401 may raise the session-expired signal.
session.set({ signedIn: true, authHealthy: "401" });
} else {
// Transient failure (network blip / 5xx / malformed body): keep the
// previous authHealthy verdict — pre-fix this branch asserted "401"
// and the debug menu falsely showed "Sign in again — your session
// expired" on every offline poll (audit #34).
session.set({ signedIn: true });
}
// Don't clobber a live status-bar ad — it owns the item until it reverts,
// at which point statusBarAd calls showActive() again (adShowing=false)
// to paint these freshly-fetched figures.
if (isAdShowing()) return;
statusBar.set({ kind: "active", version: ccVersion,
usd: lastUsd, usdToday: lastToday });
};
// Piggybacked balances repaint immediately — this replaces the old
// "schedule a /v1/earnings refetch ~2.5s after a billable event" with
// zero requests: the billed metrics RESPONSE delivered the numbers, and
// showActive's fast path above paints them without touching the network.
signals?.onEarningsUpdated(() => void showActive());
// Initial status bar state + sign-in nudge.
if (!auth.accessToken()) {
statusBar.set({ kind: "signed-out" });
const NUDGE_KEY = "kickbacks.signinNudge.shownAt";
const NUDGE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
const lastShownAt = Number(ctx.globalState.get<number>(NUDGE_KEY) || 0);
if (Date.now() - lastShownAt > NUDGE_COOLDOWN_MS) {
void ctx.globalState.update(NUDGE_KEY, Date.now());
void (async () => {
try {
const choice = await vscode.window.showInformationMessage?.(
"Kickbacks: sign in to start earning on Claude Code spinner ads.",
"Sign in", "Later");
if (choice === "Sign in") {
await vscode.commands.executeCommand("kickbacks.signIn");
}
} catch { /* toast is best-effort */ }
})();
}
} else void showActive();
return { showActive, scheduleEarningsRefresh };
}