Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion web/admin/lib/__tests__/wall-spend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
PARITY_DAY,
parityDelta,
listenUsdPerAudioHour,
SONIOX_LINE,
sonioxSpendByDay,
spendDailyQuery,
spendDays,
variableCostPerPayingUserMonth,
Expand Down Expand Up @@ -59,13 +61,15 @@ describe("spendDays", () => {
variable_usd: "10.5",
listen_usd: null,
all_in_usd: "100",
soniox_usd: null,
rows: "4",
},
{
day: "2026-11-04",
variable_usd: null,
listen_usd: "2",
all_in_usd: "3549.21",
soniox_usd: "1.25",
rows: 6,
},
]);
Expand All @@ -74,12 +78,44 @@ describe("spendDays", () => {
expect(days[0].listenUsd).toBe(2);
expect(days[1].listenUsd).toBeNull();
expect(days[1].variableUsd).toBe(10.5);
expect(days[0].sonioxUsd).toBe(1.25);
expect(days[1].sonioxUsd).toBeNull();
});
});

describe("sonioxSpendByDay", () => {
it("maps only days with a stt_soniox row, never a missing day to $0", () => {
const day = (d: string, sonioxUsd: number | null) => ({
day: d,
variableUsd: null,
listenUsd: null,
allInUsd: null,
sonioxUsd,
rows: 1,
});
expect(
sonioxSpendByDay([day("2026-11-04", 3.5), day("2026-11-05", null)])
).toEqual({ "2026-11-04": 3.5 });
expect(sonioxSpendByDay([])).toEqual({});
});

it("the query reads the Soniox line alone, on the variable basis", () => {
const sql = spendDailyQuery("2026-11-04", 7);
expect(sql).toContain(`line = '${SONIOX_LINE}'`);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
expect(sql).toContain("AS soniox_usd");
});
});

describe("parityDelta", () => {
const day = (allInUsd: number | null) => [
{ day: PARITY_DAY, variableUsd: null, listenUsd: null, allInUsd, rows: 1 },
{
day: PARITY_DAY,
variableUsd: null,
listenUsd: null,
allInUsd,
sonioxUsd: null,
rows: 1,
},
];

it("passes at the contract value to the cent", () => {
Expand Down Expand Up @@ -114,6 +150,7 @@ describe("variableCostPerPayingUserMonth", () => {
variableUsd,
listenUsd: null,
allInUsd: null,
sonioxUsd: null,
rows: 1,
});

Expand Down
116 changes: 116 additions & 0 deletions web/admin/lib/__tests__/wall-vendor-balance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const state = vi.hoisted(() => ({
query: vi.fn(),
}));

vi.mock("@/lib/services/gcp-billing", () => ({
getGcpBillingClient: () => ({ query: state.query }),
}));

import {
darkVendorBalance,
fetchVendorBalanceHistory,
isVendorBalanceDark,
VENDOR_BALANCE_DARK_REASON,
} from "../wall-finops";
import { bigQueryFailure } from "../wall-source-error";

const TABLES = [
"omi_finops.vendor_balance_snapshot",
"omi_finops.vendor_balance_spend_daily",
];
const FALLBACK = "Soniox measured STT unavailable (balance line dark)";

describe("vendor balance dark state: classifier", () => {
it("marks a missing vendor-balance table as dark with a non-error reason", () => {
const failure = bigQueryFailure(
{
code: 404,
message:
"Not found: Table based-hardware:omi_finops.vendor_balance_snapshot was not found",
},
TABLES,
FALLBACK
);
expect(failure.message).toBe(
"omi_finops.vendor_balance_snapshot not found (table not created yet)"
);
expect(isVendorBalanceDark(failure)).toBe(true);
});

it("keeps a grant denial on a vendor-balance table as an error", () => {
const failure = bigQueryFailure(
{
code: 403,
message:
"Access Denied: Table based-hardware:omi_finops.vendor_balance_spend_daily: User does not have permission",
},
TABLES,
FALLBACK
);
expect(failure.message).toContain("missing dataViewer grant");
expect(isVendorBalanceDark(failure)).toBe(false);
});

it("keeps an unnamed or unrelated failure as an error", () => {
const unnamed = bigQueryFailure(
{ code: 404, message: "Not found" },
TABLES,
FALLBACK
);
expect(isVendorBalanceDark(unnamed)).toBe(false);
expect(unnamed.message).toBe(FALLBACK);

const timeout = bigQueryFailure(
{ code: 500, message: "Job timed out" },
TABLES,
FALLBACK
);
expect(isVendorBalanceDark(timeout)).toBe(false);
});

it("returns an empty history carrying the dark reason", () => {
const dark = darkVendorBalance();
expect(dark.dark).toBe(VENDOR_BALANCE_DARK_REASON);
expect(dark.dark).toBe("Soniox balance not tracked (org key off)");
expect(dark.snapshots).toBe(0);
expect(dark.spend).toEqual({});
expect(dark.flags).toEqual({});
});
});

describe("vendor balance dark state: production catch path", () => {
beforeEach(() => {
state.query.mockReset();
});

it("resolves dark when the vendor-balance table does not exist yet", async () => {
state.query.mockRejectedValue({
code: 404,
message:
"Not found: Table based-hardware:omi_finops.vendor_balance_snapshot was not found",
});
await expect(fetchVendorBalanceHistory("2026-11-08")).resolves.toEqual(
darkVendorBalance()
);
});

it("rejects on a grant denial, so the leg stays an error", async () => {
state.query.mockRejectedValue({
code: 403,
message:
"Access Denied: Table based-hardware:omi_finops.vendor_balance_spend_daily: User does not have permission",
});
await expect(fetchVendorBalanceHistory("2026-11-08")).rejects.toThrow(
/missing dataViewer grant/
);
});

it("rejects on an unrelated failure, so the leg stays an error", async () => {
state.query.mockRejectedValue({ code: 500, message: "Job timed out" });
await expect(fetchVendorBalanceHistory("2026-11-08")).rejects.toThrow(
/balance line dark/
);
});
});
62 changes: 61 additions & 1 deletion web/admin/lib/wall-finops.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { BigQueryDate } from "@google-cloud/bigquery";
import { getGcpBillingClient } from "@/lib/services/gcp-billing";
import { bigQueryFailure, WallSourceError } from "./wall-source-error";
import {
spendDailyQuery,
spendDays,
type SpendDay,
type SpendDayRow,
} from "./wall-spend";

/** Every `omi_finops` table the admin reads. The reader identity
* (`GCP_BILLING_SA_JSON`, omi-admin-billing-ro) holds table-level dataViewer
Expand Down Expand Up @@ -519,6 +525,30 @@ export interface VendorBalanceHistory {
spend: Record<string, number | null>;
/** date → producer flag ('' normal, 'gap_spread', 'baseline_reset'). */
flags: Record<string, string>;
/** Set when the vendor-balance tables are not created yet: the leg completes
* dark with this reason instead of failing. */
dark?: string;
}

/** Shown on the Soniox line while the vendor-balance tables do not exist. */
export const VENDOR_BALANCE_DARK_REASON =
"Soniox balance not tracked (org key off)";

export function darkVendorBalance(): VendorBalanceHistory {
return {
snapshots: 0,
latestTs: null,
asOf: null,
spend: {},
flags: {},
dark: VENDOR_BALANCE_DARK_REASON,
};
}

/** True only for the not-created-yet 404 on a vendor-balance table, as
* bigQueryFailure classifies it. Grants (403) and other errors stay errors. */
export function isVendorBalanceDark(failure: WallSourceError): boolean {
return /\(table not created yet\)$/.test(failure.message);
}

export async function fetchVendorBalanceHistory(
Expand All @@ -538,18 +568,48 @@ export async function fetchVendorBalanceHistory(
jobTimeoutMs: 30_000,
});
} catch (error) {
throw bigQueryFailure(
const failure = bigQueryFailure(
error,
[
"omi_finops.vendor_balance_snapshot",
"omi_finops.vendor_balance_spend_daily",
],
"Soniox measured STT unavailable (balance line dark: org key unmounted or table not created)"
);
if (isVendorBalanceDark(failure)) return darkVendorBalance();
throw failure;
}
return parseVendorBalance(rows[0]?.history || "{}");
}

/** Settled daily spend (`omi_finops.spend_daily`) over `days` days ending on
* `today`. A table that is not created yet reads as no rows, so the Soniox
* line shows "no data yet" instead of failing the leg; other errors throw. */
export async function fetchSpendDays(
today: string,
days = HEALTH_HISTORY_DAYS
): Promise<SpendDay[]> {
const bq = getGcpBillingClient();
if (!bq) throw new WallSourceError("FinOps BigQuery client unavailable");
let rows: SpendDayRow[];
try {
[rows] = await bq.query({
query: spendDailyQuery(today, days),
maximumBytesBilled: String(10 * 1024 * 1024),
jobTimeoutMs: 30_000,
});
} catch (error) {
const failure = bigQueryFailure(
error,
["omi_finops.spend_daily"],
"Settled spend ledger unavailable (spend_daily unreadable)"
);
if (isVendorBalanceDark(failure)) return [];
throw failure;
}
return spendDays(rows);
}

export function parseVendorBalance(json: string): VendorBalanceHistory {
const parsed = JSON.parse(json) as {
spend?: { day: string; usd: number | null; flag: string | null }[];
Expand Down
Loading
Loading