From e60ef277f6b4b731109444233ae81862da0ba15e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 09:14:09 +0000 Subject: [PATCH] What the fleet costs and what it returns, in the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `crawlproof dashboard` (also roi, tui) is a live hqtui dashboard over three feeds that have never been in the same place: the tracker for who arrived, the ad network for what was delivered, and CoinPay for what the bank actually did. Five screens — ROI, Traffic, Ads, Money, Spend — refreshing on a timer, plus --json for the same snapshot without a terminal. Two rules run through the arithmetic, because breaking either produces a flattering number that is false: Self-deal is not revenue. We advertise on our own slots, so ad spend and ad earnings are one dollar moving between two pockets. They are reported under Internal and counted as neither cost nor revenue. Personal money is not fleet cost. The bank feed carries groceries next to servers, so cost is the business scope only, joined from each ledger row's account to that account's effective_scope. Everything is normalised to a monthly rate and prorated onto the traffic window, because burn is a rate and the traffic side can be asked for an hour while the bank side only answers in weeks. Two token-authed reads to feed it: GET /api/ads/v1/earnings — the model /dashboard/ads/earnings already builds. Without it a client wanting fleet totals needs one request per campaign, and the account is past 170 of them. GET /api/tracker/v1/sites — /stats already names every project, but only inside the 400 it returns when the caller does not say which. A client should not have to parse an error message to find the fleet. loadEarnings now filters ad_campaigns, ad_slots, ad_ledger and ad_payouts by owner_id explicitly rather than leaning on RLS. For the dashboard that is a no-op narrowing of what RLS already allows; for the new route, which passes the service client, it is the security boundary. The dashboard reports what it cannot know as loudly as what it can: a site that did not answer is missing rather than zero, a vendor list built from one page of a longer ledger says so, and a fleet whose visits run 200x its pageviews — or whose average is really one busy site — says that too, next to the number. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HvWJ4336pxTFRdRbvsTQeD --- app/api/ads/v1/earnings/route.ts | 38 ++ app/api/tracker/v1/sites/route.ts | 33 ++ cli/dashboard.ts | 856 ++++++++++++++++++++++++++++++ cli/index.ts | 106 +++- lib/ads/earnings-data.ts | 18 +- lib/dashboard/collect.ts | 251 +++++++++ lib/dashboard/roi.ts | 391 ++++++++++++++ lib/tracker/apiStats.ts | 13 +- package-lock.json | 541 +++++++++++++++++++ package.json | 2 + tests/dashboard-collect.test.ts | 71 +++ tests/dashboard-roi.test.ts | 210 ++++++++ 12 files changed, 2522 insertions(+), 8 deletions(-) create mode 100644 app/api/ads/v1/earnings/route.ts create mode 100644 app/api/tracker/v1/sites/route.ts create mode 100644 cli/dashboard.ts create mode 100644 lib/dashboard/collect.ts create mode 100644 lib/dashboard/roi.ts create mode 100644 tests/dashboard-collect.test.ts create mode 100644 tests/dashboard-roi.test.ts diff --git a/app/api/ads/v1/earnings/route.ts b/app/api/ads/v1/earnings/route.ts new file mode 100644 index 0000000..629501c --- /dev/null +++ b/app/api/ads/v1/earnings/route.ts @@ -0,0 +1,38 @@ +// /api/ads/v1/earnings — the account's ad money and delivery, for a token caller. +// +// GET ?days=7|30|90|365 +// +// The same model /dashboard/ads/earnings renders, for something holding an API +// token. It exists because the alternative for a client that wants fleet totals +// is one /campaigns/[id] request per campaign, and the account is past 170 of +// them — see `crawlproof dashboard`, which polls this on a timer. +// +// Same auth as the rest of /api/ads/v1/*: `Authorization: Bearer crp_…`. + +import { NextResponse, type NextRequest } from "next/server"; + +import { serviceClient } from "@/lib/supabase/service"; +import { authenticateBearer } from "@/lib/sp/apiAuth"; +import { loadEarnings } from "@/lib/ads/earnings-data"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const ALLOWED_DAYS = [7, 30, 90, 365]; + +/** An unknown window falls back to 30 rather than reaching the query planner. */ +export function parseDays(raw: string | null): number { + const n = Number(raw); + return ALLOWED_DAYS.includes(n) ? n : 30; +} + +export async function GET(req: NextRequest) { + const auth = await authenticateBearer(req); + if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const days = parseDays(req.nextUrl.searchParams.get("days")); + // The service client has no RLS. loadEarnings filters every table by + // owner_id itself, which is what makes passing it here safe. + const model = await loadEarnings(serviceClient(), auth.userId, days); + return NextResponse.json(model); +} diff --git a/app/api/tracker/v1/sites/route.ts b/app/api/tracker/v1/sites/route.ts new file mode 100644 index 0000000..b7a7e90 --- /dev/null +++ b/app/api/tracker/v1/sites/route.ts @@ -0,0 +1,33 @@ +// /api/tracker/v1/sites — the projects this token can read stats for. +// +// GET → { sites: [{ id, name, url, tracker_enabled }] } +// +// /stats already names them, but only inside the 400 it returns when the +// account has more than one and the caller did not say which. A client that +// wants the whole fleet should not have to parse an error message to find it. + +import { NextResponse, type NextRequest } from "next/server"; + +import { serviceClient } from "@/lib/supabase/service"; +import { authenticateBearer } from "@/lib/sp/apiAuth"; +import { listProjects } from "@/lib/tracker/apiStats"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const auth = await authenticateBearer(req); + if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const listed = await listProjects(serviceClient(), auth.userId); + if (!listed.ok) return NextResponse.json({ error: listed.error }, { status: listed.status }); + + return NextResponse.json({ + sites: listed.projects.map((p) => ({ + id: p.id, + name: p.name, + url: p.url, + tracker_enabled: p.tracker_enabled ?? null, + })), + }); +} diff --git a/cli/dashboard.ts b/cli/dashboard.ts new file mode 100644 index 0000000..6033497 --- /dev/null +++ b/cli/dashboard.ts @@ -0,0 +1,856 @@ +// `crawlproof dashboard` — what the fleet costs and what it returns, live. +// +// Five screens over three feeds: CrawlProof's tracker for who arrived, its ad +// network for what was delivered, and CoinPay for what the bank actually did. +// The interesting screen is the first one, because it is the only place those +// three meet and the only place the answer is a ratio rather than a total. +// +// Built on @profullstack/hqtui, the same library behind `coinpay finances`. + +import type { Container, RenderArgs, Theme } from "@profullstack/hqtui"; + +import { collectDashboard, type CoinPayAuth, type DashboardSnapshot } from "../lib/dashboard/collect"; + +export const TABS = ["ROI", "Traffic", "Ads", "Money", "Spend"] as const; +export const RANGES = ["1h", "4h", "1d", "1w", "1m"] as const; + +/** Which tracker range pairs with which CoinPay window. */ +export const FINANCE_DAYS: Record = { + "1h": 7, + "4h": 7, + "1d": 7, + "1w": 7, + "1m": 30, +}; + +// ── formatting ── + +const num = (v: unknown): number => { + const x = Number(v); + return Number.isFinite(x) ? x : 0; +}; + +export function money(value: unknown, { compact = false, cents = false } = {}): string { + const v = num(value); + const abs = Math.abs(v); + const sign = v < 0 ? "-" : ""; + if (compact && abs >= 1_000_000) return `${sign}$${(abs / 1_000_000).toFixed(1)}M`; + if (compact && abs >= 1_000) return `${sign}$${(abs / 1_000).toFixed(1)}k`; + const digits = cents || abs < 10 ? 2 : abs < 1_000 ? 2 : 0; + return `${sign}$${abs.toLocaleString("en-US", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + })}`; +} + +export function count(value: unknown): string { + return num(value).toLocaleString("en-US"); +} + +export function pct(value: number | null | undefined, digits = 1): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + return `${(value * 100).toFixed(digits)}%`; +} + +/** A ratio people can act on, rather than a decimal they have to convert. */ +export function ratio(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(0)}%`; +} + +export function ago(at: Date | string | null): string { + if (!at) return "never"; + const then = at instanceof Date ? at.getTime() : Date.parse(at); + if (!Number.isFinite(then)) return "never"; + const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (secs < 60) return `${secs}s ago`; + if (secs < 3600) return `${Math.round(secs / 60)}m ago`; + return `${Math.round(secs / 3600)}h ago`; +} + +const clock = () => new Date().toLocaleTimeString("en-US", { hour12: false }); + +// ── state ── + +type Pane = { selected: number; offset: number; total: number }; + +type State = { + tab: number; + range: string; + who: string; + snapshot: DashboardSnapshot | null; + loading: boolean; + lastRefresh: Date | null; + error: string | null; + paused: boolean; + showHelp: boolean; + panes: Record; +}; + +function pane(state: State, name: string, total: number): Pane { + let p = state.panes[name]; + if (!p) { + p = { selected: 0, offset: 0, total: 0 }; + state.panes[name] = p; + } + p.total = total; + const max = Math.max(0, total - 1); + p.offset = Math.min(p.offset, max); + p.selected = Math.min(p.selected, max); + return p; +} + +function scrollPane(p: Pane, delta: number, rows = 1): void { + const max = Math.max(0, p.total - 1); + p.offset = Math.max(0, Math.min(p.offset + delta * rows, max)); + p.selected = Math.max(p.offset, Math.min(p.selected, max)); +} + +const TAB_PANE = ["vendors", "sites", "campaigns", "invoices", "ledger"]; + +const signed = (theme: Theme, v: number) => (v >= 0 ? theme.success : theme.danger); + +// ── screens ── + +/** + * The one screen that answers the question in the command's name. + * + * Cost and revenue are both monthly rates, because burn is a rate; the window + * column beside them is that rate prorated onto whatever traffic window is + * selected, which is the only way the per-visitor numbers mean anything. + */ +function roiScreen(ui: Container, state: State, theme: Theme): void { + const s = state.snapshot as DashboardSnapshot; + const r = s.roi; + const win = `${state.range}`; + + ui.grid({ columns: ["1fr", "1fr", "1fr"], rows: [12, 11, "1fr"], gap: 1 }, (grid) => { + grid.panel( + { + title: "The number", + subtitle: r.cost.scopeMissing ? "whole bank feed" : "business scope", + subtitleColor: r.cost.scopeMissing ? theme.warning : theme.muted, + }, + (p) => { + p.keyValues( + [ + { label: "Cost", value: `${money(r.cost.perMonthUsd)}/mo`, color: theme.danger }, + { label: "Revenue", value: `${money(r.revenue.perMonthUsd)}/mo`, color: theme.success }, + { + label: "Net", + value: `${money(r.derived.netPerMonthUsd)}/mo`, + color: signed(theme, r.derived.netPerMonthUsd), + }, + { + label: "ROI", + value: ratio(r.derived.roi), + color: (r.derived.roi ?? -1) >= 0 ? theme.success : theme.danger, + }, + { + label: "Months of cover", + value: r.derived.monthsOfCover === null ? "—" : r.derived.monthsOfCover.toFixed(1), + color: (r.derived.monthsOfCover ?? 0) < 3 ? theme.warning : theme.success, + }, + { label: "", value: "" }, + { label: `Cost · ${win}`, value: money(r.cost.windowUsd) }, + { label: `Revenue · ${win}`, value: money(r.revenue.windowUsd) }, + ], + { labelWidth: 17 }, + ); + }, + ); + + grid.panel({ title: "Per visitor", subtitle: `${win} · ${state.who}` }, (p) => { + p.keyValues( + [ + { label: "Visitors", value: count(r.attention.visitors), color: theme.primary }, + { label: "Pageviews", value: count(r.attention.pageviews) }, + { + label: "Cost each", + value: r.derived.costPerVisitorUsd === null ? "—" : money(r.derived.costPerVisitorUsd, { cents: true }), + color: theme.danger, + }, + { + label: "Revenue each", + value: + r.derived.revenuePerVisitorUsd === null ? "—" : money(r.derived.revenuePerVisitorUsd, { cents: true }), + color: theme.success, + }, + { + label: "Cost per view", + value: + r.derived.costPerPageviewUsd === null ? "—" : money(r.derived.costPerPageviewUsd, { cents: true }), + color: theme.danger, + }, + { + label: "Break-even", + value: + r.derived.breakEvenVisitors === null + ? "—" + : `${count(Math.ceil(r.derived.breakEvenVisitors))} visitors/mo`, + color: theme.warning, + }, + { label: "", value: "" }, + { + label: "Sites reporting", + value: `${r.attention.sitesReporting} of ${r.attention.sites}`, + color: r.attention.sitesReporting < r.attention.sites ? theme.warning : theme.muted, + }, + ], + { labelWidth: 16 }, + ); + }); + + grid.panel( + { title: "Internal", subtitle: "one account, both sides", subtitleColor: theme.muted }, + (p) => { + p.text("Ad money moving between our own products.", { fg: theme.muted }); + p.text("Counted as neither cost nor revenue.", { fg: theme.muted }); + p.keyValues( + [ + { label: "Ad spend", value: money(r.internal.adSpendUsd) }, + { label: "Ad earnings", value: money(r.internal.adEarnedUsd) }, + { + label: "Net", + value: money(r.internal.netUsd), + color: Math.abs(r.internal.netUsd) < 1 ? theme.muted : theme.warning, + }, + { label: "Available", value: money(r.internal.availableUsd) }, + ], + { labelWidth: 14 }, + ); + }, + ); + + grid.panel({ title: "Where the money goes", colSpan: 2 }, (p) => { + const vendors = r.cost.vendors.slice(0, 8); + if (!vendors.length) { + p.text("No business debits in the window.", { fg: theme.muted }); + return; + } + const max = Math.max(1, ...vendors.map((v) => v.usd)); + p.meters( + vendors.map((v) => ({ + label: v.payee.slice(0, 18), + value: v.usd, + max, + text: money(v.usd, { compact: true }), + })), + { labelWidth: 19, valueWidth: 8 }, + ); + }); + + grid.panel({ title: "Reach" }, (p) => { + p.keyValues( + [ + { label: "Impressions", value: count(r.attention.impressions) }, + { label: "Clicks", value: count(r.attention.clicks) }, + { label: "CTR", value: pct(r.attention.ctr, 2) }, + { label: "", value: "" }, + { label: "Merchant volume", value: `${money(r.revenue.grossVolumePerMonthUsd, { compact: true })}/mo` }, + { label: "Our commission", value: `${money(r.revenue.commissionPerMonthUsd)}/mo`, color: theme.success }, + ], + { labelWidth: 17 }, + ); + }); + + grid.panel({ title: "Read this before quoting a number", colSpan: 3 }, (p) => { + if (!r.caveats.length) { + p.text("Every source answered and every figure is scoped as labelled.", { fg: theme.success }); + return; + } + for (const c of r.caveats) p.text(`· ${c}`, { fg: theme.warning }); + }); + }); +} + +function trafficScreen(ui: Container, state: State, theme: Theme): void { + const s = state.snapshot as DashboardSnapshot; + const rows = s.sites; + + ui.grid({ columns: ["3fr", "2fr"], gap: 1 }, (grid) => { + grid.panel( + { + title: `Sites · ${state.range} · ${state.who}`, + subtitle: `${s.roi.attention.sitesReporting} of ${rows.length} reporting`, + footer: "j/k scroll", + }, + (p) => { + const view = pane(state, "sites", rows.length); + p.table({ + columns: [ + { + key: "site", + title: "Site", + width: 28, + // A site that did not answer is coloured, not silently ordinary. + color: (row: { note?: string }) => (row.note ? theme.danger : undefined), + }, + { key: "visitors", title: "Visitors", align: "right", width: 10 }, + { key: "pageviews", title: "Views", align: "right", width: 9 }, + { key: "cost", title: "Cost", align: "right", width: 10 }, + { key: "note", title: "", width: 16, color: theme.danger }, + ], + rows: rows.map((row) => { + const share = s.roi.attention.visitors > 0 ? row.visitors / s.roi.attention.visitors : 0; + return { + site: row.site, + visitors: row.error ? "—" : count(row.visitors), + pageviews: row.error ? "—" : count(row.pageviews), + cost: row.error ? "—" : money(s.roi.cost.windowUsd * share, { cents: true }), + note: row.error ? row.error.slice(0, 16) : "", + }; + }), + offset: view.offset, + selected: view.selected, + scrollbar: true, + onScroll: (delta: number) => scrollPane(view, delta, 3), + }); + }, + ); + + grid.cell({ gap: 1 }, (col) => { + col.panel({ title: "Where they came from" }, (p) => { + if (!s.fleet.sources.length) { + p.text("Nobody arrived in this window.", { fg: theme.muted }); + return; + } + const max = Math.max(1, ...s.fleet.sources.map((x) => x.value)); + p.meters( + s.fleet.sources.slice(0, 8).map((x) => ({ + label: x.label.slice(0, 22), + value: x.value, + max, + text: count(x.value), + })), + { labelWidth: 23, valueWidth: 7 }, + ); + }); + + col.panel({ title: "Most-read pages" }, (p) => { + if (!s.fleet.pages.length) { + p.text("No pages read in this window.", { fg: theme.muted }); + return; + } + p.keyValues( + s.fleet.pages.slice(0, 10).map((x) => ({ + label: x.label.slice(0, 30), + value: count(x.value), + })), + { labelWidth: 31 }, + ); + }); + }); + }); +} + +function adsScreen(ui: Container, state: State, theme: Theme): void { + const s = state.snapshot as DashboardSnapshot; + const ads = s.ads; + + if (!ads) { + ui.panel({ title: "Ads" }, (p) => { + p.text(s.errors.ads ?? "Ad earnings unavailable.", { fg: theme.danger }); + p.text("Press r to retry.", { fg: theme.muted }); + }); + return; + } + + const t = ads.totals ?? {}; + const spent = num(t.spentCents) / 100; + const earned = num(t.earnedCents) / 100; + + ui.grid({ columns: ["1fr", "1fr", "1fr"], rows: [13, "1fr"], gap: 1 }, (grid) => { + grid.panel({ title: "As advertiser", subtitle: `${ads.rangeDays ?? "?"}d delivery` }, (p) => { + p.keyValues( + [ + { label: "Impressions", value: count(t.advImpressions) }, + { label: "Clicks", value: count(t.advClicks) }, + { + label: "CTR", + value: pct(num(t.advImpressions) > 0 ? num(t.advClicks) / num(t.advImpressions) : null, 2), + }, + { label: "Spend (lifetime)", value: money(spent), color: theme.warning }, + ], + { labelWidth: 19 }, + ); + }); + + grid.panel({ title: "As publisher", subtitle: `${ads.rangeDays ?? "?"}d delivery` }, (p) => { + p.keyValues( + [ + { label: "Impressions", value: count(t.pubImpressions) }, + { label: "Clicks", value: count(t.pubClicks) }, + { + label: "CTR", + value: pct(num(t.pubImpressions) > 0 ? num(t.pubClicks) / num(t.pubImpressions) : null, 2), + }, + { + label: "Invalid clicks", + value: count(t.invalidClicks), + color: num(t.invalidClicks) > 0 ? theme.warning : theme.muted, + }, + { label: "Earned (lifetime)", value: money(earned), color: theme.success }, + { label: "Available", value: money(num(t.availableCents) / 100) }, + ], + { labelWidth: 19 }, + ); + }); + + grid.panel({ title: "Net of the network" }, (p) => { + p.text("We advertise on our own slots, so these", { fg: theme.muted }); + p.text("two sides are the same dollar.", { fg: theme.muted }); + p.keyValues( + [ + { label: "Spend", value: `-${money(spent)}` }, + { label: "Earned", value: `+${money(earned)}` }, + { + label: "Net", + value: money(earned - spent), + color: Math.abs(earned - spent) < 1 ? theme.muted : theme.warning, + }, + ], + { labelWidth: 12 }, + ); + if (ads.statsUnavailable) { + p.text("A delivery query failed; counts are zero-filled.", { fg: theme.danger }); + } + }); + + grid.panel({ title: "Ad-driven arrivals", colSpan: 3, subtitle: "sources bucketed as Ad · …" }, (p) => { + const adSources = s.fleet.sources.filter((x) => x.label.startsWith("Ad ·")); + if (!adSources.length) { + p.text("No arrivals attributed to an ad in this window.", { fg: theme.muted }); + return; + } + const max = Math.max(1, ...adSources.map((x) => x.value)); + p.meters( + adSources.slice(0, 10).map((x) => ({ + label: x.label.replace(/^Ad · /, "").slice(0, 24), + value: x.value, + max, + text: count(x.value), + })), + { labelWidth: 25, valueWidth: 7 }, + ); + }); + }); +} + +function moneyScreen(ui: Container, state: State, theme: Theme): void { + const s = state.snapshot as DashboardSnapshot; + const f = s.finance; + + if (!f) { + ui.panel({ title: "Money" }, (p) => { + p.text(s.errors.finance ?? "CoinPay unavailable.", { fg: theme.danger }); + p.text("`coinpay auth login` writes the session this reads.", { fg: theme.muted }); + }); + return; + } + + const e = f.earnings ?? {}; + const bank = (f as { bank?: Record }).bank ?? {}; + const cash = (bank.cashflow ?? {}) as Record; + const invoices = (f as { invoices?: { totals?: Record; counts?: Record } }) + .invoices ?? {}; + + ui.grid({ columns: ["1fr", "1fr", "1fr"], rows: [12, "1fr"], gap: 1 }, (grid) => { + grid.panel({ title: `Earnings · ${s.window.financeDays}d` }, (p) => { + p.keyValues( + [ + { label: "Gross volume", value: money(e.grossVolumeUsd), color: theme.primary }, + { label: "Commission", value: money(e.commissionUsd), color: theme.success }, + { label: "Net", value: money(e.netUsd) }, + ], + { labelWidth: 16 }, + ); + }); + + grid.panel({ title: "Bank & cards" }, (p) => { + p.keyValues( + [ + { label: "Assets", value: money(bank.assets), color: theme.success }, + { label: "Owed", value: money(bank.liabilities), color: theme.danger }, + { label: "Net", value: money(bank.net), color: signed(theme, num(bank.net)) }, + { label: `In · ${s.window.financeDays}d`, value: money(cash.moneyIn), color: theme.success }, + { label: `Out · ${s.window.financeDays}d`, value: money(cash.moneyOut), color: theme.danger }, + { label: "Accounts", value: count(bank.accountCount) }, + ], + { labelWidth: 16 }, + ); + }); + + grid.panel({ title: "Owed to us" }, (p) => { + p.keyValues( + [ + { + label: "Outstanding", + value: `${money(invoices.totals?.outstanding)} (${count(invoices.counts?.outstanding)})`, + color: theme.warning, + }, + { + label: "Overdue", + value: `${money(invoices.totals?.overdue)} (${count(invoices.counts?.overdue)})`, + color: num(invoices.counts?.overdue) > 0 ? theme.danger : theme.muted, + }, + { + label: "Paid", + value: `${money(invoices.totals?.paid)} (${count(invoices.counts?.paid)})`, + color: theme.success, + }, + { label: "Draft", value: `${money(invoices.totals?.draft)} (${count(invoices.counts?.draft)})` }, + ], + { labelWidth: 14 }, + ); + }); + + grid.panel({ title: "Income vs spending", colSpan: 3, subtitle: "by month, from the bank feed" }, (p) => { + const months = (f.position as { months?: Array> })?.months ?? []; + if (!months.length) { + p.text("Not enough bank history to plot a month.", { fg: theme.muted }); + return; + } + p.multiGraph( + [ + { values: months.map((m) => num(m.income)), color: theme.success, label: "income", fill: true }, + { values: months.map((m) => num(m.spending)), color: theme.danger, label: "spending" }, + ], + { + min: 0, + axis: true, + axisFormat: (v: number) => money(v, { compact: true }), + timeAxis: months.map((m) => String(m.month ?? "")), + legend: true, + }, + ); + }); + }); +} + +function spendScreen(ui: Container, state: State, theme: Theme): void { + const s = state.snapshot as DashboardSnapshot; + const r = s.roi; + + if (!s.finance) { + ui.panel({ title: "Spend" }, (p) => { + p.text(s.errors.finance ?? "CoinPay unavailable.", { fg: theme.danger }); + }); + return; + } + + const bank = (s.finance as { bank?: Record }).bank ?? {}; + const categories = (bank.topCategories ?? []) as Array<{ category: string | null; spent: number; count: number }>; + + ui.grid({ columns: ["3fr", "2fr"], gap: 1 }, (grid) => { + grid.panel( + { + title: "Who we pay", + subtitle: r.cost.vendorsPartial ? "newest page of the ledger" : "the whole window", + subtitleColor: r.cost.vendorsPartial ? theme.warning : theme.muted, + footer: "business accounts only", + }, + (p) => { + const rows = r.cost.vendors; + if (!rows.length) { + p.text("No business debits in the window.", { fg: theme.muted }); + return; + } + const view = pane(state, "ledger", rows.length); + p.table({ + columns: [ + { key: "payee", title: "Payee", width: 30 }, + { key: "usd", title: "Spent", align: "right", width: 12 }, + { key: "charges", title: "Charges", align: "right", width: 9 }, + { key: "share", title: "Share", align: "right", width: 8 }, + ], + rows: rows.map((v) => ({ + payee: v.payee, + usd: money(v.usd), + charges: count(v.charges), + share: pct(r.cost.perMonthUsd > 0 ? v.usd / r.cost.perMonthUsd : null, 0), + })), + offset: view.offset, + selected: view.selected, + scrollbar: true, + onScroll: (delta: number) => scrollPane(view, delta, 3), + }); + }, + ); + + grid.cell({ gap: 1 }, (col) => { + col.panel({ title: "Burn" }, (p) => { + p.keyValues( + [ + { label: "Business", value: `${money(r.cost.perMonthUsd)}/mo`, color: theme.danger }, + { label: "All accounts", value: `${money(r.cost.allScopesPerMonthUsd)}/mo`, color: theme.muted }, + { label: "", value: "" }, + { label: "Revenue", value: `${money(r.revenue.perMonthUsd)}/mo`, color: theme.success }, + { + label: "Net", + value: `${money(r.derived.netPerMonthUsd)}/mo`, + color: signed(theme, r.derived.netPerMonthUsd), + }, + ], + { labelWidth: 15 }, + ); + }); + + col.panel({ title: "By category", subtitle: "all accounts" }, (p) => { + if (!categories.length) { + p.text("Nothing categorised yet.", { fg: theme.muted }); + return; + } + const max = Math.max(1, ...categories.map((c) => num(c.spent))); + p.meters( + categories.slice(0, 9).map((c) => ({ + label: (c.category ?? "uncategorised").slice(0, 16), + value: num(c.spent), + max, + text: money(c.spent, { compact: true }), + })), + { labelWidth: 17, valueWidth: 8 }, + ); + }); + }); + }); +} + +const SCREENS = [roiScreen, trafficScreen, adsScreen, moneyScreen, spendScreen]; + +// ── app ── + +async function loadHqtui(): Promise { + try { + return await import("@profullstack/hqtui"); + } catch (err) { + const [major, minor] = process.versions.node.split(".").map(Number); + const tooOld = (major ?? 0) < 22 || ((major ?? 0) === 22 && (minor ?? 0) < 6); + const message = tooOld + ? `The dashboard needs Node 22.6 or newer (you have ${process.versions.node}).` + : `Could not load @profullstack/hqtui: ${(err as Error)?.message ?? err}`; + throw new Error(`${message} Use \`crawlproof stats\` for a plain-text answer.`); + } +} + +export type DashboardOptions = { + baseUrl: string; + token: string; + range?: string; + who?: string; + interval?: number; + concurrency?: number; + coinpay: CoinPayAuth | null; + only?: string[] | null; + theme?: string; +}; + +export async function runDashboard(opts: DashboardOptions): Promise { + const hqtui = await loadHqtui(); + const app = await hqtui.createApp({ + fps: 30, + theme: (opts.theme as never) || "dark", + quitKeys: ["ctrl+c", "q"], + }); + + const state: State = { + tab: 0, + range: opts.range && RANGES.includes(opts.range as never) ? opts.range : "1d", + who: opts.who ?? "humans", + snapshot: null, + loading: false, + lastRefresh: null, + error: null, + paused: false, + showHelp: false, + panes: {}, + }; + + let refreshing = false; + + async function refresh(): Promise { + if (refreshing) return; + refreshing = true; + state.loading = true; + app.invalidate(); + try { + state.snapshot = await collectDashboard({ + baseUrl: opts.baseUrl, + token: opts.token, + range: state.range, + who: state.who, + financeDays: FINANCE_DAYS[state.range] ?? 30, + concurrency: opts.concurrency ?? 8, + coinpay: opts.coinpay, + only: opts.only ?? null, + }); + state.error = null; + state.lastRefresh = new Date(); + } catch (err) { + state.error = err instanceof Error ? err.message : String(err); + } finally { + state.loading = false; + refreshing = false; + app.invalidate(); + } + } + + const interval = Math.max(10, opts.interval ?? 60); + const poll = setInterval(() => { + if (!state.paused) void refresh(); + }, interval * 1000); + poll.unref?.(); + + const tick = setInterval(() => app.invalidate(), 1000); + tick.unref?.(); + + app.on("key", (event: { name: string; shift?: boolean }) => { + if (state.showHelp) { + state.showHelp = false; + app.invalidate(); + return; + } + const digit = Number(event.name); + if (Number.isInteger(digit) && event.name.length === 1 && digit >= 1 && digit <= TABS.length) { + state.tab = digit - 1; + app.invalidate(); + return; + } + const view = pane(state, TAB_PANE[state.tab] as string, state.panes[TAB_PANE[state.tab] as string]?.total ?? 0); + switch (event.name) { + case "tab": + case "right": + case "l": + state.tab = event.shift ? (state.tab + TABS.length - 1) % TABS.length : (state.tab + 1) % TABS.length; + break; + case "left": + case "h": + state.tab = (state.tab + TABS.length - 1) % TABS.length; + break; + case "r": + case "f5": + void refresh(); + break; + case "w": + state.range = RANGES[(RANGES.indexOf(state.range as never) + 1) % RANGES.length] as string; + void refresh(); + break; + case "b": + state.who = state.who === "humans" ? "all" : state.who === "all" ? "bots" : "humans"; + void refresh(); + break; + case "p": + case "space": + state.paused = !state.paused; + break; + case "?": + case "f1": + state.showHelp = true; + break; + case "up": + case "k": + scrollPane(view, -1); + break; + case "down": + case "j": + scrollPane(view, 1); + break; + case "pageup": + scrollPane(view, -1, 10); + break; + case "pagedown": + scrollPane(view, 1, 10); + break; + default: + return; + } + app.invalidate(); + }); + + app.render(({ ui, theme, height }: RenderArgs) => { + ui.row({ size: 1 }, (header) => { + header.text(" CrawlProof ", { fg: theme.title, bold: true, size: 12 }); + header.tabs({ + tabs: TABS.map((name, i) => `${i + 1} ${name}`), + active: state.tab, + onSelect: (index: number) => { + state.tab = index; + }, + }); + const right = [ + state.paused ? "paused" : state.loading ? "loading…" : `${state.range} · ${state.who}`, + state.lastRefresh ? `updated ${ago(state.lastRefresh)}` : "starting", + clock(), + ].join(" "); + header.text(`${right} `, { + fg: state.paused ? theme.warning : state.error ? theme.danger : theme.success, + align: "right", + }); + }); + ui.spacer(1); + + ui.column({ size: height - 4 }, (body) => { + if (!state.snapshot) { + body.panel({ title: "Spend & ROI" }, (p) => { + if (state.error) { + p.text(`Could not load: ${state.error}`, { fg: theme.danger }); + p.text("Press r to retry, q to quit.", { fg: theme.muted }); + } else { + p.text("Reading the fleet…", { fg: theme.muted }); + p.text("One tracker call per site, plus ad earnings and CoinPay.", { fg: theme.muted }); + } + }); + return; + } + (SCREENS[state.tab] ?? roiScreen)(body, state, theme); + }); + + ui.spacer(1); + const errorCount = state.snapshot ? Object.keys(state.snapshot.errors).length : 0; + ui.statusBar({ + items: [ + { key: "1-5", label: "Screen" }, + { key: "r", label: "Refresh" }, + { key: "w", label: `Window ${state.range}` }, + { key: "b", label: state.who }, + { key: "p", label: state.paused ? "Resume" : "Pause", active: state.paused }, + { key: "?", label: "Help" }, + { key: "q", label: "Quit" }, + ], + right: errorCount + ? [{ label: `${errorCount} source${errorCount > 1 ? "s" : ""} unavailable`, color: theme.warning }] + : [{ label: "all sources live", color: theme.success }], + }); + + if (state.showHelp) { + ui.modal({ + title: "CrawlProof — Spend & ROI", + width: 70, + height: 22, + message: + "1-5, Tab, ←/→ switch screens.\n" + + `r refreshes now; it also refreshes every ${interval}s.\n` + + "w cycles the window: 1h → 4h → 1d → 1w → 1m.\n" + + "b cycles who counts: humans → all → bots.\n" + + "p pauses the timer. ↑/↓ j/k, PgUp/PgDn scroll a table.\n\n" + + "Cost is business-scope bank spend as a monthly rate, so it\n" + + " does not move when you change the traffic window.\n" + + "Revenue is CoinPay commission only. Ad spend and ad earnings\n" + + " are the same account on both sides of our own network, so\n" + + " they are reported under Internal and counted as neither.\n" + + "Cost each = the monthly burn prorated onto the window,\n" + + " divided by the visitors who arrived in it.\n\n" + + "Press any key to close.", + buttons: [{ label: "Close", focused: true }], + }); + } + }); + + app.on("exit", () => { + clearInterval(poll); + clearInterval(tick); + }); + + void refresh(); + await app.start(); +} diff --git a/cli/index.ts b/cli/index.ts index 105420f..17c79e6 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -13,6 +13,8 @@ // involvement — handy for local debugging. `report` fetches a public report // by share-token from the production API. +import { readFileSync } from "node:fs"; + import { isAllowedTargetUrl } from "../lib/rateLimit"; type Args = { @@ -429,6 +431,89 @@ async function cmdSlots(args: Args): Promise { return 2; } +/** + * The CoinPay merchant session the finance half of the dashboard needs. + * + * Same file `coinpay auth login` writes, because asking someone to paste a JWT + * they already have on disk is not a login flow. Absent is fine: the dashboard + * runs without it and says which panels are missing. + */ +function coinpayAuth(args: Args): { token: string; baseUrl: string } | null { + // CoinPay's SDK wants a base that includes /api, but COINPAY_API_URL is the + // site origin everywhere else in this repo (it is set that way in the + // production environment). Accept either and normalise, because the failure + // otherwise is an HTML page parsed as JSON, which names neither cause. + const configured = ( + (args.flags["coinpay-url"] as string | undefined) ?? + process.env.COINPAY_API_URL ?? + "https://coinpayportal.com/api" + ).replace(/\/$/, ""); + const baseUrl = /\/api$/.test(configured) ? configured : `${configured}/api`; + + const fromEnv = process.env.COINPAY_SESSION_TOKEN?.trim(); + if (fromEnv) return { token: fromEnv, baseUrl }; + + try { + const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; + const file = process.env.COINPAY_CONFIG ?? `${home}/.coinpay.json`; + const token = (JSON.parse(readFileSync(file, "utf8")) as { jwtToken?: string }).jwtToken; + return token ? { token: token.trim(), baseUrl } : null; + } catch { + return null; + } +} + +async function cmdDashboard(args: Args): Promise { + const token = apiToken(args); + if (!token) { + console.error("Set CRAWLPROOF_TOKEN (or --token) to a crp_… API token from Social → API tokens."); + return 2; + } + + const range = (args.flags.range as string | undefined) ?? "1d"; + const who = (args.flags.who as string | undefined) ?? "humans"; + const only = typeof args.flags.sites === "string" ? args.flags.sites.split(",").map((s) => s.trim()).filter(Boolean) : null; + const coinpay = args.flags["no-coinpay"] ? null : coinpayAuth(args); + + // --json is the same snapshot the screens render, for a script or a check + // that cannot open a terminal. + if (args.flags.json) { + const { collectDashboard } = await import("../lib/dashboard/collect"); + const { FINANCE_DAYS } = await import("./dashboard"); + const snapshot = await collectDashboard({ + baseUrl: apiBase(args), + token, + range, + who, + financeDays: FINANCE_DAYS[range] ?? 30, + concurrency: Number(args.flags.concurrency) || 8, + coinpay, + only, + }); + process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`); + return 0; + } + + if (!process.stdout.isTTY) { + console.error("The dashboard needs a terminal. Use --json for a snapshot, or `crawlproof stats` for one site."); + return 2; + } + + const { runDashboard } = await import("./dashboard"); + await runDashboard({ + baseUrl: apiBase(args), + token, + range, + who, + interval: Number(args.flags.interval) || 60, + concurrency: Number(args.flags.concurrency) || 8, + coinpay, + only, + theme: args.flags.theme as string | undefined, + }); + return 0; +} + function help() { console.log(`crawlproof — AEO audit CLI (stub) @@ -492,6 +577,14 @@ COMMANDS month of crawler traffic. The site is a hostname, a project id or a project name; with one project it can be left out. Needs an API token. + dashboard [--range=1h|4h|1d|1w|1m] [--who=humans|bots|all] [--interval=60] + [--sites=a.com,b.com] [--concurrency=8] [--no-coinpay] [--json] + A live terminal dashboard of what the fleet costs and what it returns: + traffic across every site you own, ad delivery, and — when a CoinPay + merchant session is on the box — the bank feed behind it. Five screens: + ROI, Traffic, Ads, Money, Spend. Needs an API token and a terminal; + --json prints the same snapshot for a script. Aliases: roi, tui. + help Print this message. @@ -499,7 +592,12 @@ ENV ANTHROPIC_API_KEY Required for --engine=claude. CRAWLPROOF_SITE_URL Override the API base URL for 'report', 'sweep', 'track', 'ads' and 'slots'. CRAWLPROOF_PROJECT Default project UUID for 'track'. - CRAWLPROOF_TOKEN API token (crp_…) for 'ads' and 'slots'; --token overrides. + CRAWLPROOF_TOKEN API token (crp_…) for 'ads', 'slots', 'stats' and + 'dashboard'; --token overrides. + COINPAY_SESSION_TOKEN CoinPay merchant JWT for the money half of + 'dashboard'. Defaults to jwtToken in ~/.coinpay.json, + which 'coinpay auth login' writes. + COINPAY_API_URL CoinPay API base (default https://coinpayportal.com/api). CRON_SECRET Required for 'sweep'. EXAMPLES @@ -511,6 +609,8 @@ EXAMPLES crawlproof track --project=ac4e0a7d-... --event=signup --target=hero_cta CRAWLPROOF_TOKEN=crp_... crawlproof ads create https://nichedb.dev --name "NicheDB" CRAWLPROOF_TOKEN=crp_... crawlproof slots create nichedb.dev + CRAWLPROOF_TOKEN=crp_... crawlproof dashboard --range=1w + CRAWLPROOF_TOKEN=crp_... crawlproof dashboard --json | jq .roi.derived `); } @@ -532,6 +632,10 @@ async function main() { return await cmdSlots(args); case "stats": return await cmdStats(args); + case "dashboard": + case "roi": + case "tui": + return await cmdDashboard(args); case "help": case "--help": case "-h": diff --git a/lib/ads/earnings-data.ts b/lib/ads/earnings-data.ts index 486d337..e4c8f09 100644 --- a/lib/ads/earnings-data.ts +++ b/lib/ads/earnings-data.ts @@ -14,8 +14,12 @@ import { // Unified money model for one account — a single user is both an advertiser // (ad_campaigns.owner_id) and a publisher (ad_slots.owner_id), so the earnings -// page shows BOTH spend and earnings. Everything is RLS-scoped to the caller; -// pass a request-scoped Supabase client whose auth.uid() is the account. +// page shows BOTH spend and earnings. +// +// Every query is filtered by `owner_id` explicitly rather than leaning on RLS. +// The dashboard passes a request-scoped client where that filter is a no-op +// narrowing of what RLS already allows; /api/ads/v1/earnings passes the service +// client, which has no RLS at all, and there the filter IS the boundary. export type MoneyDailyPoint = { date: string; spentCents: number; earnedCents: number }; @@ -132,7 +136,10 @@ export async function loadEarnings( { data: ledgerData }, { data: payoutsData }, ] = await Promise.all([ - supabase.from("ad_campaigns").select("id, name, status, total_spent_cents, spend_today_cents, spend_date"), + supabase + .from("ad_campaigns") + .select("id, name, status, total_spent_cents, spend_today_cents, spend_date") + .eq("owner_id", userId), // Not ad_campaign_stats / ad_slot_stats: those views are lifetime and count // only tier 'paid', so on a network running entirely on free backfill they // report zero for every campaign and every site. The RPCs take a window and @@ -140,12 +147,13 @@ export async function loadEarnings( getCampaignTotalsSince(supabase, since), // Monetization is owner-only (payouts go to the slot owner), like /ads/slots. supabase.from("projects").select("id, name").eq("owner_id", userId), - supabase.from("ad_slots").select("id, project_id, status"), + supabase.from("ad_slots").select("id, project_id, status").eq("owner_id", userId), getSlotTotalsSince(supabase, since), - supabase.from("ad_ledger").select("slot_id, amount_cents").eq("kind", "publisher_accrual"), + supabase.from("ad_ledger").select("slot_id, amount_cents").eq("kind", "publisher_accrual").eq("owner_id", userId), supabase .from("ad_payouts") .select("amount_cents, currency, status, tx_hash, created_at") + .eq("owner_id", userId) .order("created_at", { ascending: false }), ]); diff --git a/lib/dashboard/collect.ts b/lib/dashboard/collect.ts new file mode 100644 index 0000000..30bc466 --- /dev/null +++ b/lib/dashboard/collect.ts @@ -0,0 +1,251 @@ +// Gathering the three feeds the ROI dashboard joins. +// +// CrawlProof answers per project, so the fleet is a fan-out: one /stats call +// per site, concurrency-capped. That is deliberate rather than a missing +// server-side aggregate — summing 50-odd projects inside one serverless +// request is how the tracker RPCs have timed out before, and a slow client is +// a much better failure than a route that 504s for everybody. +// +// CoinPay is one call into its own SDK, which is the whole point: the finance +// dashboard already exists and this reads it rather than reimplementing it. +// +// Nothing here throws for a partial answer. A source that fails lands in +// `errors` and its panel says so, because a dashboard that hides a dead feed +// behind a zero is worse than one that says the feed is dead. + +import { + buildRoi, + type AdsInput, + type FinanceInput, + type RoiModel, + type SiteTraffic, +} from "./roi"; + +export type ListItem = { label: string; value: number }; + +export type SiteStats = SiteTraffic & { + id?: string; + url?: string; + sources: ListItem[]; + referrers: ListItem[]; + pages: ListItem[]; +}; + +export type DashboardSnapshot = { + generatedAt: string; + window: { range: string; who: string; financeDays: number }; + sites: SiteStats[]; + fleet: { sources: ListItem[]; referrers: ListItem[]; pages: ListItem[] }; + ads: AdsInput | null; + finance: FinanceInput | null; + roi: RoiModel; + /** Source name → why it is missing. Empty when everything answered. */ + errors: Record; +}; + +const TIMEOUT_MS = 20_000; + +async function fetchJson(url: string, token: string, timeoutMs = TIMEOUT_MS): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + signal: controller.signal, + }); + const text = await res.text(); + let body: unknown; + try { + body = text ? JSON.parse(text) : {}; + } catch { + throw new Error(`${res.status} ${res.statusText}: not JSON`); + } + if (!res.ok) { + const message = (body as { error?: string })?.error ?? `${res.status} ${res.statusText}`; + throw new Error(message); + } + return body as T; + } finally { + clearTimeout(timer); + } +} + +/** Run `fn` over `items`, at most `limit` in flight. */ +export async function mapLimit( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + const out = new Array(items.length); + let next = 0; + const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => { + for (;;) { + const i = next++; + if (i >= items.length) return; + out[i] = await fn(items[i] as T, i); + } + }); + await Promise.all(workers); + return out; +} + +/** Merge per-site lists into one fleet list, largest first. */ +export function mergeLists(lists: ListItem[][], limit = 12): ListItem[] { + const total = new Map(); + for (const list of lists) { + for (const item of list ?? []) { + const label = String(item?.label ?? "").trim(); + if (!label) continue; + total.set(label, (total.get(label) ?? 0) + (Number(item?.value) || 0)); + } + } + return [...total.entries()] + .map(([label, value]) => ({ label, value })) + .sort((a, b) => b.value - a.value) + .slice(0, limit); +} + +type SiteRow = { id: string; name: string; url: string; tracker_enabled?: boolean | null }; + +export async function listSites(baseUrl: string, token: string): Promise { + const body = await fetchJson<{ sites?: SiteRow[] }>(`${baseUrl}/api/tracker/v1/sites`, token); + return body.sites ?? []; +} + +async function statsForSite( + baseUrl: string, + token: string, + site: SiteRow, + range: string, + who: string, +): Promise { + const url = `${baseUrl}/api/tracker/v1/stats?site=${encodeURIComponent(site.id)}&range=${encodeURIComponent(range)}&who=${encodeURIComponent(who)}`; + try { + const body = await fetchJson<{ + totals?: { visitors?: number; pageviews?: number }; + sources?: ListItem[]; + referrers?: ListItem[]; + pages?: ListItem[]; + }>(url, token); + return { + site: site.name, + id: site.id, + url: site.url, + visitors: Number(body.totals?.visitors) || 0, + pageviews: Number(body.totals?.pageviews) || 0, + sources: body.sources ?? [], + referrers: body.referrers ?? [], + pages: body.pages ?? [], + }; + } catch (err) { + return { + site: site.name, + id: site.id, + url: site.url, + visitors: 0, + pageviews: 0, + sources: [], + referrers: [], + pages: [], + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export type CoinPayAuth = { token: string; baseUrl: string }; + +/** + * The CoinPay finance snapshot, via CoinPay's own SDK. + * + * Imported lazily so that a box without the package — or without a merchant + * session — still gets a traffic and ads dashboard instead of a stack trace. + */ +export async function collectFinance( + auth: CoinPayAuth, + days: number, +): Promise { + const [{ default: CoinPayClient }, finances] = await Promise.all([ + import("@profullstack/coinpay"), + import("@profullstack/coinpay/finances"), + ]); + const client = new CoinPayClient({ apiKey: auth.token, baseUrl: auth.baseUrl }); + // 500 rather than the default page: the vendor breakdown is only honest if + // the ledger it groups covers the whole window. + return (await finances.collectFinanceSnapshot(client, { days, limit: 500 })) as FinanceInput; +} + +export type CollectOptions = { + baseUrl: string; + token: string; + range: string; + who: string; + financeDays: number; + concurrency?: number; + coinpay: CoinPayAuth | null; + /** Limit the fan-out to these site names or ids. */ + only?: string[] | null; +}; + +export async function collectDashboard(opts: CollectOptions): Promise { + const errors: Record = {}; + + const sitesPromise = listSites(opts.baseUrl, opts.token).catch((err: unknown) => { + errors.sites = err instanceof Error ? err.message : String(err); + return [] as SiteRow[]; + }); + + const adsPromise = fetchJson( + `${opts.baseUrl}/api/ads/v1/earnings?days=${encodeURIComponent(String(opts.financeDays))}`, + opts.token, + ).catch((err: unknown) => { + errors.ads = err instanceof Error ? err.message : String(err); + return null; + }); + + const financePromise = opts.coinpay + ? collectFinance(opts.coinpay, opts.financeDays).catch((err: unknown) => { + errors.finance = err instanceof Error ? err.message : String(err); + return null; + }) + : Promise.resolve(null); + if (!opts.coinpay) { + errors.finance = "No CoinPay session. Run `coinpay auth login`, or set COINPAY_SESSION_TOKEN."; + } + + let siteRows = await sitesPromise; + if (opts.only?.length) { + const wanted = new Set(opts.only.map((s) => s.toLowerCase())); + siteRows = siteRows.filter((s) => wanted.has(s.name.toLowerCase()) || wanted.has(s.id)); + } + + const sites = await mapLimit(siteRows, opts.concurrency ?? 8, (site) => + statsForSite(opts.baseUrl, opts.token, site, opts.range, opts.who), + ); + sites.sort((a, b) => b.visitors - a.visitors || a.site.localeCompare(b.site)); + + const failed = sites.filter((s) => s.error).length; + if (failed) errors.stats = `${failed} of ${sites.length} sites did not answer`; + + const [ads, finance] = await Promise.all([adsPromise, financePromise]); + + const roi = buildRoi({ + traffic: { range: opts.range, who: opts.who, sites }, + ads, + finance, + }); + + return { + generatedAt: new Date().toISOString(), + window: { range: opts.range, who: opts.who, financeDays: opts.financeDays }, + sites, + fleet: { + sources: mergeLists(sites.map((s) => s.sources)), + referrers: mergeLists(sites.map((s) => s.referrers)), + pages: mergeLists(sites.map((s) => s.pages)), + }, + ads, + finance, + roi, + errors, + }; +} diff --git a/lib/dashboard/roi.ts b/lib/dashboard/roi.ts new file mode 100644 index 0000000..6d3e5dd --- /dev/null +++ b/lib/dashboard/roi.ts @@ -0,0 +1,391 @@ +// What the fleet costs, what it returns, and the ratio between them. +// +// Three sources answer three different questions and none of them answers this +// one alone: CrawlProof knows who showed up, the ad network knows what was +// delivered, and CoinPay knows what the bank actually did. This module is the +// arithmetic that joins them, kept pure so it can be tested against real +// shapes without a network. +// +// Two rules run through all of it, and both exist because breaking either one +// produces a flattering number that is false: +// +// 1. **Self-deal is not revenue.** The ad network runs with one account on +// both sides — we advertise on our own slots — so `spentCents` and +// `earnedCents` are the same dollar leaving one pocket and arriving in the +// other. They are reported under `internal`, never added to revenue, and +// `internal.net` should stay near zero. If it ever does not, that is a +// bug in the ledger rather than a profit. +// 2. **Personal money is not fleet cost.** The bank feed carries one human's +// groceries next to the servers. Only the `business` scope is spend. +// +// Everything is normalised to a **monthly rate** and then prorated onto the +// traffic window. Burn is a rate, not a balance, and a rate survives the fact +// that the traffic side can be asked for an hour while the bank side only +// answers in weeks. + +/** Days covered by each tracker range, for prorating a monthly rate onto it. */ +export const RANGE_DAYS: Record = { + "1h": 1 / 24, + "4h": 1 / 6, + "1d": 1, + "1w": 7, + "1m": 30, +}; + +export function rangeDays(range: string): number { + return RANGE_DAYS[range] ?? 1; +} + +const n = (v: unknown): number => { + const x = Number(v); + return Number.isFinite(x) ? x : 0; +}; +const cents = (v: unknown): number => n(v) / 100; + +/** One site's slice of the fleet's traffic. */ +export type SiteTraffic = { + site: string; + visitors: number; + pageviews: number; + /** Set when this site's stats call failed; its numbers are zero, not observed. */ + error?: string; +}; + +export type TrafficInput = { + range: string; + who: string; + sites: SiteTraffic[]; +}; + +/** The subset of /api/ads/v1/earnings this module reads. */ +export type AdsInput = { + rangeDays?: number; + statsUnavailable?: boolean; + totals?: { + spentCents?: number; + earnedCents?: number; + availableCents?: number; + advImpressions?: number; + advClicks?: number; + pubImpressions?: number; + pubClicks?: number; + invalidClicks?: number; + }; +}; + +/** The subset of the CoinPay finance snapshot this module reads. */ +export type FinanceInput = { + windowDays?: number; + earnings?: { commissionUsd?: number; grossVolumeUsd?: number; netUsd?: number }; + position?: { + lookbackDays?: number; + monthsObserved?: number; + spending?: { perMonth?: number }; + income?: { perMonth?: number }; + ratios?: { monthsOfCover?: number | null }; + scopes?: Array<{ + scope?: string; + spending?: number; + income?: number; + accounts?: number; + }>; + }; + bank?: { + /** + * A ledger row names an account, not a scope — `effective_scope` lives on + * the account. Joining the two is what keeps groceries out of the fleet's + * bill, so both halves are required. + */ + accounts?: Array<{ id?: string; effective_scope?: string | null; name?: string | null }>; + ledger?: Array<{ + account_id?: string | null; + payee?: string | null; + description?: string | null; + amount?: number | null; + category?: string | null; + posted?: string | null; + }>; + /** Rows in the window; `ledger` may be one page of it. */ + ledgerTotal?: number; + }; +}; + +export type VendorSpend = { payee: string; usd: number; charges: number }; + +export type RoiModel = { + window: { range: string; days: number; who: string }; + cost: { + /** Business-scope burn, the rate everything else is prorated from. */ + perMonthUsd: number; + /** That rate over the traffic window. */ + windowUsd: number; + /** True when the bank feed never labelled an account business. */ + scopeMissing: boolean; + /** Whole-feed burn including personal, for context only. */ + allScopesPerMonthUsd: number; + vendors: VendorSpend[]; + /** True when `vendors` was built from one page of a longer ledger. */ + vendorsPartial: boolean; + }; + revenue: { + /** Money from outside the fleet: the only kind that counts. */ + perMonthUsd: number; + windowUsd: number; + commissionPerMonthUsd: number; + grossVolumePerMonthUsd: number; + }; + /** Money moving between our own products. Never revenue; see rule 1. */ + internal: { + adSpendUsd: number; + adEarnedUsd: number; + netUsd: number; + availableUsd: number; + }; + attention: { + visitors: number; + pageviews: number; + sites: number; + sitesReporting: number; + impressions: number; + clicks: number; + ctr: number | null; + }; + derived: { + netPerMonthUsd: number; + /** (revenue − cost) / cost. Null when there is no cost to divide by. */ + roi: number | null; + costPerVisitorUsd: number | null; + /** + * The sturdier denominator. A "visitor" here is any visit the tracker did + * not classify as a crawler, which on a site with a machine-readable + * endpoint runs orders of magnitude above the pages anyone actually read. + */ + costPerPageviewUsd: number | null; + revenuePerVisitorUsd: number | null; + /** Visitors per month needed to cover burn at the current revenue/visitor. */ + breakEvenVisitors: number | null; + monthsOfCover: number | null; + }; + /** Why a number is missing or should not be read straight. */ + caveats: string[]; +}; + +/** + * Sum a fleet's worth of per-site stats. + * + * Sites whose stats call failed are counted in `sites` but not in + * `sitesReporting`, so a partial fan-out cannot quietly read as a quiet day. + */ +export function sumTraffic(sites: SiteTraffic[]): { + visitors: number; + pageviews: number; + sites: number; + sitesReporting: number; +} { + let visitors = 0; + let pageviews = 0; + let reporting = 0; + for (const s of sites) { + if (s.error) continue; + reporting += 1; + visitors += n(s.visitors); + pageviews += n(s.pageviews); + } + return { visitors, pageviews, sites: sites.length, sitesReporting: reporting }; +} + +/** + * Business-scope spend as a monthly rate. + * + * Prefers the per-scope split; falls back to the whole-feed rate when no + * account has been marked business, and says so through `scopeMissing` rather + * than silently billing the fleet for someone's groceries. + */ +export function businessBurn(finance: FinanceInput): { + perMonthUsd: number; + allScopesPerMonthUsd: number; + scopeMissing: boolean; +} { + const position = finance.position ?? {}; + const allScopes = n(position.spending?.perMonth); + const months = n(position.monthsObserved); + const scopes = position.scopes ?? []; + const business = scopes.find((s) => s?.scope === "business"); + + // `scopes[].spending` is a total over the lookback, not a rate; the observed + // month count is what turns it into one. + if (business && months > 0) { + return { + perMonthUsd: n(business.spending) / months, + allScopesPerMonthUsd: allScopes, + scopeMissing: false, + }; + } + return { perMonthUsd: allScopes, allScopesPerMonthUsd: allScopes, scopeMissing: true }; +} + +/** Ids of the accounts the feed considers business. */ +export function businessAccountIds(finance: FinanceInput): Set { + const ids = new Set(); + for (const a of finance.bank?.accounts ?? []) { + if (a?.id && a.effective_scope === "business") ids.add(a.id); + } + return ids; +} + +/** + * Who we actually pay, largest first. + * + * Debits only, business accounts only, and grouped by payee so twelve + * Anthropic charges read as one line with a number worth acting on. With no + * business account marked the whole feed is used, which is wrong but visibly + * wrong — `buildRoi` raises the same caveat for the burn rate. + */ +export function vendorSpend(finance: FinanceInput, limit = 12): VendorSpend[] { + const rows = finance.bank?.ledger ?? []; + const business = businessAccountIds(finance); + const byPayee = new Map(); + for (const row of rows) { + const amount = n(row?.amount); + if (amount >= 0) continue; // credits and refunds are not spend + if (business.size > 0 && !business.has(String(row?.account_id ?? ""))) continue; + const payee = (row?.payee || row?.description || "Unknown").trim() || "Unknown"; + const prev = byPayee.get(payee) ?? { payee, usd: 0, charges: 0 }; + prev.usd += Math.abs(amount); + prev.charges += 1; + byPayee.set(payee, prev); + } + return [...byPayee.values()].sort((a, b) => b.usd - a.usd).slice(0, limit); +} + +/** Turn a figure covering `days` into a monthly rate. */ +export function toMonthly(total: number, days: number): number { + if (!(days > 0)) return 0; + return (n(total) * 30) / days; +} + +export function buildRoi(input: { + traffic: TrafficInput; + ads: AdsInput | null; + finance: FinanceInput | null; +}): RoiModel { + const { traffic } = input; + const ads = input.ads ?? {}; + const finance = input.finance ?? {}; + const days = rangeDays(traffic.range); + const caveats: string[] = []; + + const attention = sumTraffic(traffic.sites ?? []); + const adTotals = ads.totals ?? {}; + const impressions = n(adTotals.pubImpressions); + const clicks = n(adTotals.pubClicks); + + const burn = businessBurn(finance); + const costWindow = (burn.perMonthUsd * days) / 30; + + // Commission is our cut of merchant volume and the only line here that is + // money from outside the fleet. + const financeDays = n(finance.windowDays) || 30; + const commissionPerMonth = toMonthly(n(finance.earnings?.commissionUsd), financeDays); + const grossPerMonth = toMonthly(n(finance.earnings?.grossVolumeUsd), financeDays); + const revenuePerMonth = commissionPerMonth; + const revenueWindow = (revenuePerMonth * days) / 30; + + const adSpendUsd = cents(adTotals.spentCents); + const adEarnedUsd = cents(adTotals.earnedCents); + + const netPerMonth = revenuePerMonth - burn.perMonthUsd; + const roi = burn.perMonthUsd > 0 ? (revenuePerMonth - burn.perMonthUsd) / burn.perMonthUsd : null; + + const costPerVisitor = attention.visitors > 0 ? costWindow / attention.visitors : null; + const costPerPageview = attention.pageviews > 0 ? costWindow / attention.pageviews : null; + const revenuePerVisitor = attention.visitors > 0 ? revenueWindow / attention.visitors : null; + const breakEvenVisitors = + revenuePerVisitor && revenuePerVisitor > 0 ? burn.perMonthUsd / revenuePerVisitor : null; + + if (burn.scopeMissing) { + caveats.push( + "No account is marked business, so spend is the whole bank feed — personal included.", + ); + } + if (attention.sitesReporting < attention.sites) { + caveats.push( + `${attention.sites - attention.sitesReporting} of ${attention.sites} sites did not answer; their traffic is missing, not zero.`, + ); + } + if (ads.statsUnavailable) { + caveats.push("An ad delivery query failed; impressions and clicks are zero-filled."); + } + // A visit is any non-crawler hit, a pageview is a rendered page. When the + // first dwarfs the second the fleet is being measured by something that + // never read anything, and per-visitor money is the wrong number to quote. + if (attention.pageviews > 0 && attention.visitors > attention.pageviews * 5) { + caveats.push( + `${Math.round(attention.visitors / attention.pageviews)}× more visits than pageviews — most arrivals never rendered a page. Prefer the per-pageview figure.`, + ); + } + const busiest = [...(traffic.sites ?? [])] + .filter((s) => !s.error) + .sort((a, b) => n(b.visitors) - n(a.visitors))[0]; + if (busiest && attention.visitors > 0 && n(busiest.visitors) > attention.visitors * 0.5) { + caveats.push( + `${busiest.site} is ${Math.round((n(busiest.visitors) / attention.visitors) * 100)}% of fleet visits, so a fleet average mostly describes that one site.`, + ); + } + if (adSpendUsd > 0 || adEarnedUsd > 0) { + caveats.push( + "Ad spend and ad earnings are the same account on both sides of the network, so neither is counted as cost or revenue.", + ); + } + if (financeDays !== 30) { + caveats.push(`CoinPay figures cover ${financeDays}d, rescaled to a monthly rate.`); + } + const ledgerRows = finance.bank?.ledger?.length ?? 0; + const ledgerTotal = n(finance.bank?.ledgerTotal); + const vendorsPartial = ledgerTotal > ledgerRows; + if (vendorsPartial) { + caveats.push( + `Vendors cover the newest ${ledgerRows} of ${ledgerTotal} transactions; the burn rate above does not.`, + ); + } + + return { + window: { range: traffic.range, days, who: traffic.who }, + cost: { + perMonthUsd: burn.perMonthUsd, + windowUsd: costWindow, + scopeMissing: burn.scopeMissing, + allScopesPerMonthUsd: burn.allScopesPerMonthUsd, + vendors: vendorSpend(finance), + vendorsPartial, + }, + revenue: { + perMonthUsd: revenuePerMonth, + windowUsd: revenueWindow, + commissionPerMonthUsd: commissionPerMonth, + grossVolumePerMonthUsd: grossPerMonth, + }, + internal: { + adSpendUsd, + adEarnedUsd, + netUsd: adEarnedUsd - adSpendUsd, + availableUsd: cents(adTotals.availableCents), + }, + attention: { + ...attention, + impressions, + clicks, + ctr: impressions > 0 ? clicks / impressions : null, + }, + derived: { + netPerMonthUsd: netPerMonth, + roi, + costPerVisitorUsd: costPerVisitor, + costPerPageviewUsd: costPerPageview, + revenuePerVisitorUsd: revenuePerVisitor, + breakEvenVisitors, + monthsOfCover: finance.position?.ratios?.monthsOfCover ?? null, + }, + caveats, + }; +} diff --git a/lib/tracker/apiStats.ts b/lib/tracker/apiStats.ts index 0fb6c07..f1f67d1 100644 --- a/lib/tracker/apiStats.ts +++ b/lib/tracker/apiStats.ts @@ -39,15 +39,24 @@ export type ResolveResult = * matches it, so `crawlproof stats nichedb.dev` and `crawlproof slots create * nichedb.dev` mean the same site. */ -export async function resolveProject(sb: Sb, userId: string, site: string | null): Promise { +export async function listProjects( + sb: Sb, + userId: string, +): Promise<{ ok: true; projects: ProjectRow[] } | { ok: false; status: number; error: string }> { const { data, error } = await sb .from("projects") .select("id, name, url, tracker_enabled") .eq("owner_id", userId) .is("archived_at", null); if (error) return { ok: false, status: 500, error: error.message }; + return { ok: true, projects: (data ?? []) as ProjectRow[] }; +} + +export async function resolveProject(sb: Sb, userId: string, site: string | null): Promise { + const listed = await listProjects(sb, userId); + if (!listed.ok) return listed; - const projects = (data ?? []) as ProjectRow[]; + const projects = listed.projects; if (!projects.length) return { ok: false, status: 404, error: "No projects on this account yet." }; if (!site) { diff --git a/package-lock.json b/package-lock.json index 0fe2ab9..3949c2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "@libsql/client": "^0.17.3", "@modelcontextprotocol/sdk": "^1.26.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", + "@profullstack/coinpay": "^0.9.0", + "@profullstack/hqtui": "^0.1.11", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.1.3", "@profullstack/x402-client": "^0.2.0", @@ -1056,6 +1058,350 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", + "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.4.tgz", + "integrity": "sha512-oKT5RNeO9oKYizSyOxKb66IrKUsYVMQD2tnjllYW4wmSOe9WawfL3OE2p82JuvAyo+/nDIEem5P4Bm/ZIVYVew==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.2", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.1.tgz", + "integrity": "sha512-HvnOHal39DTenOVoRpIy8Z+n4YYfNa3Qhi/P8zFqn9/d1crpmQG0DPx34rwOeOFvotgKr9Vov/14OmKR/Iwfjw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.2.tgz", + "integrity": "sha512-9pBhkxE14uUlnHhs8lOt7qVPtS4caRY6CjADl8COejl9Mf7w8i6Uoe3DrljCqYtmYM3Iv2Q6EmPxx/oTYbvTAQ==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.2.tgz", + "integrity": "sha512-7ruKO5d/wxhGHxdCQ3eyP/aBNrPTZ+Ju7ERtnAwuIsu0RSLs6kotlTRkOxHatbD6sDaEEdwsD82pzFiT9ESIyQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/external-editor": "^3.0.5", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.4.tgz", + "integrity": "sha512-12o4aYnjWouoq0fyVhYIL+hwXAaga+kntA/wrijFmVvn2heinkdvkbEXRt1Ob/9IOJ285US5tntjfI/Ukt2YkA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.5.tgz", + "integrity": "sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.5.tgz", + "integrity": "sha512-2MkYYFD1Owt1eRBwHhsLSutysRodWasA4DVpl42KJKGmzwBTXRYAIXujisrt0UW0UohUlMjNjLfMDbw4emRbfw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.2.tgz", + "integrity": "sha512-tjW1gMIQsFb/9Ie11i8SpuPARBdyCVfC5XOeVugWovrdm8CpjzN+A1CBX5QwCCN2mtNmpgDDCnuLCCT94cQ5zA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.1.tgz", + "integrity": "sha512-InA6nJxMCXPavPBiTKYHIsYiWz/mkIsk1rkI6pKp5w18gVawqrZOj7eSzSmPJa3j0pVxXm6RlrFP1BE9YeOixA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.2", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.1.tgz", + "integrity": "sha512-xJIKWyrFNUKj3R5VEub/iKodG9Sh/MbCeApZUbATJgHTw8YoAJ2gOT78HlvO6NMFiRvjm6ySM7aiAew/zVY0Sw==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.4", + "@inquirer/confirm": "^6.3.1", + "@inquirer/editor": "^5.3.2", + "@inquirer/expand": "^5.1.4", + "@inquirer/input": "^5.1.5", + "@inquirer/number": "^4.2.2", + "@inquirer/password": "^5.2.1", + "@inquirer/rawlist": "^5.3.4", + "@inquirer/search": "^4.3.2", + "@inquirer/select": "^5.2.4" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.4.tgz", + "integrity": "sha512-c4fDwOpQsjJDHjXdHJE1/xXH6w9/BAiyu3QKzH9Ww5Xm1q37mzyUNa20L7qdtM1ECi4e+oUEKjhK8hR37OMAMQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.2.tgz", + "integrity": "sha512-YIaEhWmfkdHEWOKwhF/oJar35FFTTrAdzNbJDo9lSsRT6E/obRPx9oVm4rkTMtlcJSqGYttNtLbcHQef9Qw2yw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.4.tgz", + "integrity": "sha512-L9ubNaJdoBsiom/AfPKq0QLHTkhhCFCtszzdVidGremrlJjPZfGlmvp+IULKIbnYjtVFWnvb4mhiEdNTZ12ugA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.2", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.1.tgz", + "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@ioredis/commands": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", @@ -1663,12 +2009,72 @@ "node": ">=18" } }, + "node_modules/@profullstack/coinpay": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@profullstack/coinpay/-/coinpay-0.9.0.tgz", + "integrity": "sha512-+D0peeeue615Rl89LhYWloB6ln6JAJPuqq4VX0fSPiHq9DHL9Y+YsCXsYM9+QuVHbt8uIyv9R+jA8VrKoswMgA==", + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^8.3.0", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@profullstack/hqtui": "^0.1.11", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0" + }, + "bin": { + "coinpay": "bin/coinpay.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@profullstack/coinpay/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@profullstack/coinpay/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@profullstack/emailer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@profullstack/emailer/-/emailer-1.0.1.tgz", "integrity": "sha512-/uhHJJGH+1xSSz3mJn6X+m6aruYjMD3JOaRp/d4R/YWlzpy07H9z0/JUleIyRyBPNmaANSIwjTZ7aVjaukOEpg==", "license": "MIT" }, + "node_modules/@profullstack/hqtui": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@profullstack/hqtui/-/hqtui-0.1.11.tgz", + "integrity": "sha512-mDJBScaU+/81zpAG+hmjEvOKeLmCvx3AZ9DOTbJcVCKJdlVh77h9OAHa6UfzJPHbIGt7Xh0VwWZ9zIywyS3lgA==", + "license": "MIT", + "bin": { + "hqtui": "bin/hqtui.mjs" + }, + "engines": { + "bun": ">=1.1", + "node": ">=22.6" + } + }, "node_modules/@profullstack/referrals": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@profullstack/referrals/-/referrals-0.1.0.tgz", @@ -2058,6 +2464,81 @@ "dev": true, "license": "MIT" }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", @@ -3123,6 +3604,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "license": "MIT" + }, "node_modules/cheerio": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", @@ -3165,6 +3652,15 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -4001,6 +4497,21 @@ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", "license": "Unlicense" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", @@ -4017,6 +4528,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fast-xml-builder": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", @@ -5393,6 +5913,15 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -6597,6 +7126,18 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simplesignal": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/simplesignal/-/simplesignal-2.1.7.tgz", diff --git a/package.json b/package.json index e577761..28b2764 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "@libsql/client": "^0.17.3", "@modelcontextprotocol/sdk": "^1.26.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", + "@profullstack/coinpay": "^0.9.0", + "@profullstack/hqtui": "^0.1.11", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.1.3", "@profullstack/x402-client": "^0.2.0", diff --git a/tests/dashboard-collect.test.ts b/tests/dashboard-collect.test.ts new file mode 100644 index 0000000..ee3eb8a --- /dev/null +++ b/tests/dashboard-collect.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { mapLimit, mergeLists } from "@/lib/dashboard/collect"; +import { parseDays } from "@/app/api/ads/v1/earnings/route"; + +describe("mapLimit", () => { + it("keeps results in input order however they finish", async () => { + const out = await mapLimit([30, 10, 20, 0], 2, async (ms) => { + await new Promise((r) => setTimeout(r, ms)); + return ms; + }); + expect(out).toEqual([30, 10, 20, 0]); + }); + + it("never runs more than the limit at once", async () => { + let inFlight = 0; + let peak = 0; + await mapLimit(Array.from({ length: 20 }, (_, i) => i), 4, async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight -= 1; + }); + expect(peak).toBeLessThanOrEqual(4); + }); + + it("handles an empty list without hanging", async () => { + expect(await mapLimit([], 8, async () => 1)).toEqual([]); + }); +}); + +describe("mergeLists", () => { + it("sums a label across sites and orders by size", () => { + const merged = mergeLists([ + [ + { label: "Search · google", value: 10 }, + { label: "Social · reddit", value: 3 }, + ], + [{ label: "Search · google", value: 5 }], + ]); + expect(merged[0]).toEqual({ label: "Search · google", value: 15 }); + expect(merged[1]).toEqual({ label: "Social · reddit", value: 3 }); + }); + + it("drops blank labels and survives a missing list", () => { + const merged = mergeLists([ + [{ label: "", value: 9 }], + undefined as unknown as { label: string; value: number }[], + [{ label: "ok", value: 1 }], + ]); + expect(merged).toEqual([{ label: "ok", value: 1 }]); + }); + + it("caps the list", () => { + const many = Array.from({ length: 40 }, (_, i) => ({ label: `s${i}`, value: i })); + expect(mergeLists([many], 5)).toHaveLength(5); + }); +}); + +describe("earnings route window", () => { + it("accepts the windows the dashboard offers", () => { + expect(parseDays("7")).toBe(7); + expect(parseDays("365")).toBe(365); + }); + + it("falls back to 30 rather than passing anything else to the query", () => { + expect(parseDays(null)).toBe(30); + expect(parseDays("31")).toBe(30); + expect(parseDays("; drop table")).toBe(30); + }); +}); diff --git a/tests/dashboard-roi.test.ts b/tests/dashboard-roi.test.ts new file mode 100644 index 0000000..f05b0e7 --- /dev/null +++ b/tests/dashboard-roi.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest"; + +import { + businessAccountIds, + businessBurn, + buildRoi, + rangeDays, + sumTraffic, + toMonthly, + vendorSpend, + type FinanceInput, + type SiteTraffic, +} from "@/lib/dashboard/roi"; + +// Shapes copied from live responses, numbers invented: the real ones are one +// person's bank feed and do not belong in a repository. + +const finance = (): FinanceInput => ({ + windowDays: 30, + earnings: { commissionUsd: 120, grossVolumeUsd: 12_000, netUsd: 11_880 }, + position: { + lookbackDays: 180, + monthsObserved: 4, + spending: { perMonth: 5000 }, + income: { perMonth: 1000 }, + ratios: { monthsOfCover: 1.5 }, + scopes: [ + { scope: "personal", accounts: 3, spending: 12_000, income: 3_000 }, + { scope: "business", accounts: 2, spending: 8_000, income: 400 }, + ], + }, + bank: { + accounts: [ + { id: "biz-1", effective_scope: "business", name: "Business Card" }, + { id: "me-1", effective_scope: "personal", name: "Personal Card" }, + ], + ledgerTotal: 4, + ledger: [ + { account_id: "biz-1", payee: "Anthropic", amount: -60, category: "software" }, + { account_id: "biz-1", payee: "Anthropic", amount: -40, category: "software" }, + { account_id: "biz-1", payee: "Railway", amount: -25, category: "software" }, + { account_id: "me-1", payee: "Groceries", amount: -300, category: "food" }, + ], + }, +}); + +const ads = () => ({ + rangeDays: 30, + statsUnavailable: false, + totals: { + spentCents: 5_000, + earnedCents: 5_000, + availableCents: 1_200, + advImpressions: 900, + advClicks: 9, + pubImpressions: 1_000, + pubClicks: 20, + invalidClicks: 3, + }, +}); + +const traffic = (sites: SiteTraffic[] = [{ site: "a.com", visitors: 600, pageviews: 900 }]) => ({ + range: "1m", + who: "humans", + sites, +}); + +describe("window arithmetic", () => { + it("maps every tracker range onto days, including the sub-day ones", () => { + expect(rangeDays("1d")).toBe(1); + expect(rangeDays("1w")).toBe(7); + expect(rangeDays("1m")).toBe(30); + expect(rangeDays("4h")).toBeCloseTo(1 / 6); + expect(rangeDays("nonsense")).toBe(1); + }); + + it("rescales a window total to a monthly rate", () => { + expect(toMonthly(70, 7)).toBe(300); + expect(toMonthly(100, 30)).toBe(100); + expect(toMonthly(100, 0)).toBe(0); + }); +}); + +describe("traffic", () => { + it("counts a failed site as not reporting rather than as zero", () => { + const summed = sumTraffic([ + { site: "a", visitors: 10, pageviews: 20 }, + { site: "b", visitors: 0, pageviews: 0, error: "timeout" }, + ]); + expect(summed.visitors).toBe(10); + expect(summed.sites).toBe(2); + expect(summed.sitesReporting).toBe(1); + }); +}); + +describe("business scope", () => { + it("reads business burn as a rate from the scope total and months observed", () => { + // 8,000 over 4 observed months. + expect(businessBurn(finance()).perMonthUsd).toBe(2_000); + expect(businessBurn(finance()).scopeMissing).toBe(false); + }); + + it("falls back to the whole feed, and says so, when nothing is marked business", () => { + const f = finance(); + f.position!.scopes = [{ scope: "personal", spending: 12_000 }]; + const burn = businessBurn(f); + expect(burn.perMonthUsd).toBe(5_000); + expect(burn.scopeMissing).toBe(true); + }); + + it("picks out the business account ids", () => { + expect([...businessAccountIds(finance())]).toEqual(["biz-1"]); + }); +}); + +describe("vendors", () => { + it("groups debits by payee and leaves personal accounts out", () => { + const vendors = vendorSpend(finance()); + expect(vendors[0]).toEqual({ payee: "Anthropic", usd: 100, charges: 2 }); + expect(vendors[1]).toEqual({ payee: "Railway", usd: 25, charges: 1 }); + expect(vendors.some((v) => v.payee === "Groceries")).toBe(false); + }); + + it("ignores credits, which are not spend", () => { + const f = finance(); + f.bank!.ledger!.push({ account_id: "biz-1", payee: "Refund", amount: 50 }); + expect(vendorSpend(f).some((v) => v.payee === "Refund")).toBe(false); + }); + + it("uses the whole feed when no account is marked business", () => { + const f = finance(); + f.bank!.accounts = []; + expect(vendorSpend(f).some((v) => v.payee === "Groceries")).toBe(true); + }); +}); + +describe("buildRoi", () => { + it("never counts self-deal ad money as revenue or as cost", () => { + const model = buildRoi({ traffic: traffic(), ads: ads(), finance: finance() }); + // Commission is the only revenue; the $50 of ad earnings is not in it. + expect(model.revenue.perMonthUsd).toBe(120); + expect(model.internal.adEarnedUsd).toBe(50); + expect(model.internal.adSpendUsd).toBe(50); + expect(model.internal.netUsd).toBe(0); + // Cost is the business burn alone, with no ad spend added on top. + expect(model.cost.perMonthUsd).toBe(2_000); + expect(model.caveats.some((c) => c.includes("both sides of the network"))).toBe(true); + }); + + it("computes the ratios a spend decision actually needs", () => { + const model = buildRoi({ traffic: traffic(), ads: ads(), finance: finance() }); + expect(model.derived.roi).toBeCloseTo((120 - 2000) / 2000); + expect(model.derived.netPerMonthUsd).toBe(-1_880); + // 600 visitors in a 30d window against a $2,000/mo burn. + expect(model.derived.costPerVisitorUsd).toBeCloseTo(2000 / 600); + expect(model.derived.revenuePerVisitorUsd).toBeCloseTo(120 / 600); + expect(model.derived.breakEvenVisitors).toBeCloseTo(2000 / (120 / 600)); + expect(model.attention.ctr).toBeCloseTo(20 / 1000); + }); + + it("prorates the monthly rate onto a short window rather than comparing a month of cost to an hour of traffic", () => { + const model = buildRoi({ + traffic: traffic([{ site: "a.com", visitors: 10, pageviews: 12 }]), + ads: ads(), + finance: { ...finance(), windowDays: 30 }, + }); + expect(model.window.days).toBe(30); + + const hour = buildRoi({ + traffic: { range: "1h", who: "humans", sites: [{ site: "a.com", visitors: 10, pageviews: 12 }] }, + ads: ads(), + finance: finance(), + }); + expect(hour.cost.windowUsd).toBeCloseTo(2000 / 30 / 24); + expect(hour.cost.perMonthUsd).toBe(2_000); + }); + + it("returns null ratios instead of dividing by nothing", () => { + const model = buildRoi({ + traffic: { range: "1d", who: "humans", sites: [] }, + ads: null, + finance: null, + }); + expect(model.derived.roi).toBeNull(); + expect(model.derived.costPerVisitorUsd).toBeNull(); + expect(model.derived.breakEvenVisitors).toBeNull(); + expect(model.cost.perMonthUsd).toBe(0); + }); + + it("says when the vendor list is one page of a longer ledger", () => { + const f = finance(); + f.bank!.ledgerTotal = 400; + const model = buildRoi({ traffic: traffic(), ads: ads(), finance: f }); + expect(model.cost.vendorsPartial).toBe(true); + expect(model.caveats.some((c) => c.includes("newest 4 of 400"))).toBe(true); + }); + + it("flags missing sites so a partial fan-out cannot read as a quiet day", () => { + const model = buildRoi({ + traffic: traffic([ + { site: "a.com", visitors: 600, pageviews: 900 }, + { site: "b.com", visitors: 0, pageviews: 0, error: "500" }, + ]), + ads: ads(), + finance: finance(), + }); + expect(model.attention.sitesReporting).toBe(1); + expect(model.caveats.some((c) => c.includes("did not answer"))).toBe(true); + }); +});