← back to Kickbacks Ai Upstream

src/earnings/client.ts

114 lines

import { timeoutFetch } from "../util/http";

type Fetch = typeof fetch;

/** Which earning ceiling the user has hit (server-authoritative, tiered by
 *  account verification). Drives the red cap-warning status-bar pill. */
export interface EarningCap {
  scope: "hourly" | "daily";
  capUsd: string;
  resetSeconds: number;
}

export interface Earnings {
  lifetimeUsd: string;
  todayUsd: string;
  // Present only while a cap is hit; absent/undefined means "under both
  // ceilings" OR an older backend that doesn't send the field (back-compat).
  cap?: EarningCap | null;
}

/** Defensive parse of the optional `cap` field from /v1/earnings. Returns a
 *  well-typed EarningCap, or undefined for null / missing / malformed input —
 *  a bad cap must never poison the earnings readout (the bar just won't warn).
 *  Exported for direct unit testing. */
export function parseCap(raw: unknown): EarningCap | undefined {
  if (!raw || typeof raw !== "object") return undefined;
  const c = raw as { scope?: unknown; cap_usd?: unknown; reset_seconds?: unknown };
  if ((c.scope !== "hourly" && c.scope !== "daily")
      || typeof c.cap_usd !== "string"
      || typeof c.reset_seconds !== "number"
      || !Number.isFinite(c.reset_seconds))
    return undefined;
  return { scope: c.scope, capUsd: c.cap_usd,
           resetSeconds: Math.max(0, Math.floor(c.reset_seconds)) };
}

/** Optional auth-recovery callback. When the first GET /v1/earnings
 *  returns 401, the client calls this to refresh the access token, then
 *  retries the request exactly once with the new bearer. Without this,
 *  a transient 401 (token rotated mid-poll) leaves the status bar's
 *  `lastUsd`/`lastToday` stale OR blank on the very first poll. */
export type EarningsAuthRecovery = () => Promise<boolean>;

/** GET /v1/earnings — the user's display-only 50/50 credit (today + lifetime),
 *  for the status bar. Fail-safe: any error / signed-out => null; the status
 *  bar then renders $0.00 (it never shows a bare label and never throws). */
export class EarningsClient {
  private f: Fetch;
  constructor(private base: string, private token: () => string | null,
              f: Fetch = timeoutFetch(15000),
              private onAuth401: EarningsAuthRecovery | null = null) {
    // audit-2026-06-09 #38: extension.ts passes bare global `fetch`
    // positionally (only to reach the onAuth401 arg), silently bypassing
    // the timeout default above. Re-wrap that one case so a black-holed
    // connection still aborts; injected test/custom fetches are untouched.
    this.f = f === globalThis.fetch ? timeoutFetch(15000) : f;
  }

  async fetch(): Promise<Earnings | null> {
    const r = await this.fetchDetailed();
    return r.outcome === "ok" ? r.earnings : null;
  }

  /** audit-2026-06-09 #34: like fetch() but preserves the failure KIND so
   *  callers can distinguish a real backend 401 (session expired) from a
   *  transient network / 5xx / malformed-body failure. "401" is only
   *  reported when the backend actually said 401 AND the one-shot
   *  refresh-retry did not recover (no recovery hook, refresh failed, or
   *  the retry 401'd again). */
  async fetchDetailed(): Promise<
    { outcome: "ok"; earnings: Earnings }
    | { outcome: "401" | "error" }
  > {
    try {
      const first = await this.fetchOnce();
      if (first.outcome === "ok") return first;
      // 401 path: refresh once + retry. Any other failure (network,
      // 5xx, malformed body) reports "error" without retry — fail-fast on
      // structural problems so the caller's `lastUsd` cache holds.
      if (first.outcome === "401") {
        if (this.onAuth401 && await this.onAuth401()) {
          // A transient failure on the retry is NOT a session expiry —
          // the refresh itself just succeeded, so pass `second` through.
          const second = await this.fetchOnce();
          return second;
        }
        return { outcome: "401" };
      }
      return { outcome: "error" };
    } catch { return { outcome: "error" }; }
  }

  private async fetchOnce(): Promise<
    { outcome: "ok"; earnings: Earnings }
    | { outcome: "401" | "error" }
  > {
    try {
      const t = this.token();
      if (!t) return { outcome: "error" };
      const r = await this.f(`${this.base}/v1/earnings`,
        { headers: { authorization: `Bearer ${t}` } });
      if (r.status === 401) return { outcome: "401" };
      if (!r.ok) return { outcome: "error" };
      const j = await r.json() as {
        lifetime_usd?: string; today_usd?: string; cap?: unknown };
      if (typeof j.lifetime_usd !== "string" || typeof j.today_usd !== "string")
        return { outcome: "error" };
      return { outcome: "ok",
        earnings: { lifetimeUsd: j.lifetime_usd, todayUsd: j.today_usd,
                    cap: parseCap(j.cap) } };
    } catch { return { outcome: "error" }; }
  }
}