diff --git a/app/api/ads/v1/earnings/route.ts b/app/api/ads/v1/earnings/route.ts index 629501c..7d7666c 100644 --- a/app/api/ads/v1/earnings/route.ts +++ b/app/api/ads/v1/earnings/route.ts @@ -31,8 +31,104 @@ export async function GET(req: NextRequest) { if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status }); const days = parseDays(req.nextUrl.searchParams.get("days")); + const sb = serviceClient(); // 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); + const model = await loadEarnings(sb, auth.userId, days); + + // The windowed delivery figures come from RPCs that are `security definer` + // and filter on `auth.uid()`. A service client has no auth.uid(), so they + // return nothing and every impression count arrives as a confident zero. + // The stats views are `security_invoker` and granted to service_role, so + // they can be read directly. They are lifetime rather than windowed, which + // is why this only replaces figures that came back empty, and why the answer + // says which it gave you. + const delivery = await lifetimeDelivery(sb, model); + return NextResponse.json({ ...model, ...delivery }); +} + +type StatsRow = { + impressions: number | null; + free_impressions: number | null; + clicks: number | null; + free_clicks: number | null; +}; + +/** + * What the stats views actually mean, which is not what the column names + * suggest and is worth writing down once: + * + * impressions paid tier, non-duplicate + * free_impressions free tier, non-duplicate -> delivery is the SUM of both + * clicks **valid** clicks, either tier -> delivery is this alone + * free_clicks free tier and **not valid** -> fraud/duplicate, NOT delivery + * + * So impressions add up and clicks do not. Adding `free_clicks` into clicks + * would fold invalid clicks into the CTR, which is the one number a click + * fraud problem would show up in. + */ +const sum = (rows: StatsRow[], key: keyof StatsRow) => + rows.reduce((total, row) => total + (Number(row[key]) || 0), 0); + +/** + * Read a stats view for a list of ids, in chunks. + * + * PostgREST puts `in.(…)` in the query string, and this account is past 180 + * campaigns, so one call is a 7KB URL that comes back empty rather than + * erroring. That empty answer is exactly what a network with no delivery looks + * like, which is how it went unnoticed. + */ +async function readStats( + sb: ReturnType, + view: "ad_campaign_stats" | "ad_slot_stats", + key: "campaign_id" | "slot_id", + ids: string[], +): Promise<{ rows: StatsRow[]; failed: boolean }> { + const rows: StatsRow[] = []; + let failed = false; + const CHUNK = 50; + for (let i = 0; i < ids.length; i += CHUNK) { + const { data, error } = await sb + .from(view) + .select("impressions, free_impressions, clicks, free_clicks") + .in(key, ids.slice(i, i + CHUNK)); + if (error) failed = true; + else rows.push(...((data ?? []) as StatsRow[])); + } + return { rows, failed }; +} + +async function lifetimeDelivery( + sb: ReturnType, + model: Awaited>, +) { + const t = model.totals; + const empty = !t.advImpressions && !t.advClicks && !t.pubImpressions && !t.pubClicks; + if (!empty) return { deliveryWindow: "range" as const }; + + const campaignIds = model.campaigns.map((c) => c.id); + const slotIds = model.slots.map((s) => s.id); + if (!campaignIds.length && !slotIds.length) return { deliveryWindow: "range" as const }; + + const [c, s] = await Promise.all([ + readStats(sb, "ad_campaign_stats", "campaign_id", campaignIds), + readStats(sb, "ad_slot_stats", "slot_id", slotIds), + ]); + + return { + deliveryWindow: "lifetime" as const, + statsUnavailable: model.statsUnavailable || c.failed || s.failed, + totals: { + ...t, + advImpressions: sum(c.rows, "impressions") + sum(c.rows, "free_impressions"), + advClicks: sum(c.rows, "clicks"), + advFreeImpressions: sum(c.rows, "free_impressions"), + advPaidImpressions: sum(c.rows, "impressions"), + pubImpressions: sum(s.rows, "impressions") + sum(s.rows, "free_impressions"), + pubClicks: sum(s.rows, "clicks"), + pubFreeImpressions: sum(s.rows, "free_impressions"), + pubPaidImpressions: sum(s.rows, "impressions"), + invalidClicks: sum(c.rows, "free_clicks"), + }, + }; } diff --git a/cli/dashboard.ts b/cli/dashboard.ts index 6033497..54a1b64 100644 --- a/cli/dashboard.ts +++ b/cli/dashboard.ts @@ -10,6 +10,7 @@ import type { Container, RenderArgs, Theme } from "@profullstack/hqtui"; import { collectDashboard, type CoinPayAuth, type DashboardSnapshot } from "../lib/dashboard/collect"; +import { AD_TARGET_CTR, AD_TARGET_IMPRESSIONS, adTargets } from "../lib/dashboard/roi"; export const TABS = ["ROI", "Traffic", "Ads", "Money", "Spend"] as const; export const RANGES = ["1h", "4h", "1d", "1w", "1m"] as const; @@ -85,6 +86,8 @@ type State = { paused: boolean; showHelp: boolean; panes: Record; + targetImpressions: number; + targetCtr: number; }; function pane(state: State, name: string, total: number): Pane { @@ -128,14 +131,19 @@ function roiScreen(ui: Container, state: State, theme: Theme): void { grid.panel( { title: "The number", - subtitle: r.cost.scopeMissing ? "whole bank feed" : "business scope", + // Three different time bases meet on this panel, so each says which. + subtitle: `${r.cost.scopeMissing ? "whole bank feed" : "business scope"} · ${r.cost.lookbackDays}d avg`, 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: `Revenue (${r.revenue.observedDays}d)`, + value: `${money(r.revenue.perMonthUsd)}/mo`, + color: theme.success, + }, { label: "Net", value: `${money(r.derived.netPerMonthUsd)}/mo`, @@ -202,7 +210,7 @@ function roiScreen(ui: Container, state: State, theme: Theme): void { }); grid.panel( - { title: "Internal", subtitle: "one account, both sides", subtitleColor: theme.muted }, + { title: "Internal", subtitle: "one account, both sides · lifetime", 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 }); @@ -222,7 +230,16 @@ function roiScreen(ui: Container, state: State, theme: Theme): void { }, ); - grid.panel({ title: "Where the money goes", colSpan: 2 }, (p) => { + grid.panel( + { + title: "Where the money goes", + // The bank window, which is NOT the traffic range in the header. Bank + // data has no hourly resolution, so these differ on every range below + // a month and an unlabelled panel invites the wrong reading. + subtitle: `last ${s.window.financeDays}d · business accounts`, + 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 }); @@ -238,19 +255,33 @@ function roiScreen(ui: Container, state: State, theme: Theme): void { })), { labelWidth: 19, valueWidth: 8 }, ); - }); + }, + ); - grid.panel({ title: "Reach" }, (p) => { + grid.panel({ title: "Reach", subtitle: `ads ${s.ads?.rangeDays ?? "?"}d · money ${r.revenue.observedDays}d` }, (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: "" }, + // Both are rates built from the day series. The lifetime totals sit + // underneath them precisely so the large number is visible without + // being mistaken for a run rate. { label: "Merchant volume", value: `${money(r.revenue.grossVolumePerMonthUsd, { compact: true })}/mo` }, { label: "Our commission", value: `${money(r.revenue.commissionPerMonthUsd)}/mo`, color: theme.success }, + { + label: " lifetime volume", + value: money(r.revenue.lifetimeGrossVolumeUsd, { compact: true }), + color: theme.muted, + }, + { + label: " lifetime commission", + value: money(r.revenue.lifetimeCommissionUsd), + color: theme.muted, + }, ], - { labelWidth: 17 }, + { labelWidth: 21 }, ); }); @@ -356,48 +387,70 @@ function adsScreen(ui: Container, state: State, theme: Theme): void { return; } - const t = ads.totals ?? {}; - const spent = num(t.spentCents) / 100; - const earned = num(t.earnedCents) / 100; + const t = adTargets(ads, { targetImpressions: state.targetImpressions, targetCtr: state.targetCtr }); + const spent = num(ads.totals?.spentCents) / 100; + const earned = num(ads.totals?.earnedCents) / 100; + const window = + (ads as { deliveryWindow?: string }).deliveryWindow === "lifetime" ? "lifetime" : `${ads.rangeDays ?? "?"}d`; ui.grid({ columns: ["1fr", "1fr", "1fr"], rows: [13, "1fr"], gap: 1 }, (grid) => { - grid.panel({ title: "As advertiser", subtitle: `${ads.rangeDays ?? "?"}d delivery` }, (p) => { + // Free first, because the network is free backfill today and leading with + // the paid columns reports a working network as a dead one. + grid.panel({ title: "Delivered", subtitle: window }, (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: "Impressions", value: count(t.impressions), color: theme.primary }, + { label: " free", value: count(t.freeImpressions), color: theme.success }, + { label: " paid", value: count(t.paidImpressions), color: theme.muted }, + { label: "Clicks", value: count(t.clicks) }, + { label: "CTR", value: pct(t.ctr, 3) }, { label: "Invalid clicks", value: count(t.invalidClicks), - color: num(t.invalidClicks) > 0 ? theme.warning : theme.muted, + color: t.invalidClicks > t.clicks ? theme.danger : theme.warning, }, - { label: "Earned (lifetime)", value: money(earned), color: theme.success }, - { label: "Available", value: money(num(t.availableCents) / 100) }, ], - { labelWidth: 19 }, + { labelWidth: 16 }, ); + if (t.invalidClicks > t.clicks && t.clicks > 0) { + p.text(`${Math.round(t.invalidClicks / t.clicks)}x more invalid than valid.`, { fg: theme.danger }); + } }); - grid.panel({ title: "Net of the network" }, (p) => { + grid.panel( + { title: "Toward the target", subtitle: `${count(t.targetImpressions)}/mo · ${pct(t.targetCtr, 0)} CTR` }, + (p) => { + p.meters( + [ + { + label: "impressions", + value: Math.min(1, t.impressionProgress), + max: 1, + text: pct(t.impressionProgress, 1), + }, + { label: "CTR", value: Math.min(1, t.ctrProgress), max: 1, text: pct(t.ctrProgress, 1) }, + ], + { labelWidth: 12, valueWidth: 8 }, + ); + p.keyValues( + [ + { label: "Short by", value: count(Math.max(0, t.targetImpressions - t.impressions)) }, + { + label: "Cost per click", + value: t.cpcCents === null ? "nothing charged yet" : `${t.cpcCents.toFixed(1)}c`, + }, + { + label: "At target", + value: t.projectedMonthlyUsd === null ? "-" : `${money(t.projectedMonthlyUsd)}/mo`, + color: theme.success, + }, + ], + { labelWidth: 16 }, + ); + }, + ); + + grid.panel({ title: "Net of the network", subtitle: "one account, both sides" }, (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( @@ -409,16 +462,17 @@ function adsScreen(ui: Container, state: State, theme: Theme): void { value: money(earned - spent), color: Math.abs(earned - spent) < 1 ? theme.muted : theme.warning, }, + { label: "Available", value: money(num(ads.totals?.availableCents) / 100) }, ], { labelWidth: 12 }, ); if (ads.statsUnavailable) { - p.text("A delivery query failed; counts are zero-filled.", { fg: theme.danger }); + p.text("A delivery query failed; counts are low.", { 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 ·")); + 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; @@ -426,7 +480,7 @@ function adsScreen(ui: Container, state: State, theme: Theme): void { 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), + label: x.label.replace(/^Ad . /, "").slice(0, 24), value: x.value, max, text: count(x.value), @@ -546,7 +600,9 @@ function spendScreen(ui: Container, state: State, theme: Theme): void { grid.panel( { title: "Who we pay", - subtitle: r.cost.vendorsPartial ? "newest page of the ledger" : "the whole window", + subtitle: r.cost.vendorsPartial + ? `last ${s.window.financeDays}d · newest page of the ledger` + : `last ${s.window.financeDays}d`, subtitleColor: r.cost.vendorsPartial ? theme.warning : theme.muted, footer: "business accounts only", }, @@ -643,6 +699,9 @@ export type DashboardOptions = { coinpay: CoinPayAuth | null; only?: string[] | null; theme?: string; + /** Where the ad network is trying to get to; see lib/dashboard/roi.ts. */ + targetImpressions?: number; + targetCtr?: number; }; export async function runDashboard(opts: DashboardOptions): Promise { @@ -664,6 +723,8 @@ export async function runDashboard(opts: DashboardOptions): Promise { paused: false, showHelp: false, panes: {}, + targetImpressions: opts.targetImpressions ?? AD_TARGET_IMPRESSIONS, + targetCtr: opts.targetCtr ?? AD_TARGET_CTR, }; let refreshing = false; diff --git a/lib/dashboard/roi.ts b/lib/dashboard/roi.ts index 6d3e5dd..41f8c85 100644 --- a/lib/dashboard/roi.ts +++ b/lib/dashboard/roi.ts @@ -76,7 +76,21 @@ export type AdsInput = { /** The subset of the CoinPay finance snapshot this module reads. */ export type FinanceInput = { windowDays?: number; + /** + * The headline earnings figures, which are **lifetime and not windowed**. + * + * Verified against production 2026-09-06: asking CoinPay for 7 days and for + * 30 returns byte-identical `grossVolumeUsd`, `commissionUsd` and + * `transactions`, while the bank half of the same response does change. So + * these are a balance, not a rate, and dividing them by a window they do not + * cover invents revenue. They are reported as lifetime and never rescaled. + */ earnings?: { commissionUsd?: number; grossVolumeUsd?: number; netUsd?: number }; + /** + * Volume by day, which IS windowed. This is the only honest basis for a + * revenue rate, so it is what `revenue` is built from. + */ + series?: Array<{ label?: string; volumeUsd?: number; commissionUsd?: number; count?: number }>; position?: { lookbackDays?: number; monthsObserved?: number; @@ -126,6 +140,8 @@ export type RoiModel = { vendors: VendorSpend[]; /** True when `vendors` was built from one page of a longer ledger. */ vendorsPartial: boolean; + /** Days of bank history the burn rate is averaged over. */ + lookbackDays: number; }; revenue: { /** Money from outside the fleet: the only kind that counts. */ @@ -133,6 +149,13 @@ export type RoiModel = { windowUsd: number; commissionPerMonthUsd: number; grossVolumePerMonthUsd: number; + /** Days the series actually covers, which is what the rate is built on. */ + observedDays: number; + /** Lifetime totals, shown for context and never used as a rate. */ + lifetimeCommissionUsd: number; + lifetimeGrossVolumeUsd: number; + /** True when there was no series and the rate had to be guessed. */ + estimated: boolean; }; /** Money moving between our own products. Never revenue; see rule 1. */ internal: { @@ -264,6 +287,74 @@ export function toMonthly(total: number, days: number): number { return (n(total) * 30) / days; } +/** Where the ad network is trying to get to. Overridable from the CLI. */ +export const AD_TARGET_IMPRESSIONS = 3_000_000; +export const AD_TARGET_CTR = 0.05; + +export type AdTargets = { + impressions: number; + clicks: number; + ctr: number | null; + invalidClicks: number; + freeImpressions: number; + paidImpressions: number; + /** Share of the impression target reached, 0..1+ */ + impressionProgress: number; + ctrProgress: number; + targetImpressions: number; + targetCtr: number; + /** Cents earned per valid click today, if any money has moved at all. */ + cpcCents: number | null; + /** + * What a month at target would earn at today's cost per click. + * + * Null when nothing has ever been charged, because a revenue projection + * built on a made-up price is a forecast of the assumption, not of the + * business. + */ + projectedMonthlyUsd: number | null; +}; + +/** + * Progress toward a working ad network, and what it would be worth. + * + * The network runs entirely on free backfill right now, so the paid columns + * are near zero and leading with them would report a working network as a dead + * one. Free delivery is delivery: it is the inventory being proved. + */ +export function adTargets( + ads: AdsInput | null, + { + targetImpressions = AD_TARGET_IMPRESSIONS, + targetCtr = AD_TARGET_CTR, + cpcCents, + }: { targetImpressions?: number; targetCtr?: number; cpcCents?: number | null } = {}, +): AdTargets { + const t = ads?.totals ?? {}; + const impressions = n(t.pubImpressions); + const clicks = n(t.pubClicks); + const spent = n(t.spentCents); + const ctr = impressions > 0 ? clicks / impressions : null; + + const derivedCpc = clicks > 0 && spent > 0 ? spent / clicks : null; + const cpc = cpcCents ?? derivedCpc; + + return { + impressions, + clicks, + ctr, + invalidClicks: n(t.invalidClicks), + freeImpressions: n((t as { pubFreeImpressions?: number }).pubFreeImpressions), + paidImpressions: n((t as { pubPaidImpressions?: number }).pubPaidImpressions), + impressionProgress: targetImpressions > 0 ? impressions / targetImpressions : 0, + ctrProgress: ctr !== null && targetCtr > 0 ? ctr / targetCtr : 0, + targetImpressions, + targetCtr, + cpcCents: cpc, + projectedMonthlyUsd: cpc === null ? null : (targetImpressions * targetCtr * cpc) / 100, + }; +} + export function buildRoi(input: { traffic: TrafficInput; ads: AdsInput | null; @@ -285,9 +376,24 @@ export function buildRoi(input: { // Commission is our cut of merchant volume and the only line here that is // money from outside the fleet. + // + // It comes from the day series, not from `earnings`. The headline earnings + // figures do not move when the window changes (see the type), so they are + // lifetime; rescaling them by a window they never covered is how a dead + // merchant's historical volume becomes a six-figure monthly run rate. const financeDays = n(finance.windowDays) || 30; - const commissionPerMonth = toMonthly(n(finance.earnings?.commissionUsd), financeDays); - const grossPerMonth = toMonthly(n(finance.earnings?.grossVolumeUsd), financeDays); + const series = finance.series ?? []; + const seriesDays = series.length; + const seriesCommission = series.reduce((t, p) => t + n(p.commissionUsd), 0); + const seriesVolume = series.reduce((t, p) => t + n(p.volumeUsd), 0); + + const haveSeries = seriesDays > 0; + const commissionPerMonth = haveSeries + ? toMonthly(seriesCommission, seriesDays) + : toMonthly(n(finance.earnings?.commissionUsd), financeDays); + const grossPerMonth = haveSeries + ? toMonthly(seriesVolume, seriesDays) + : toMonthly(n(finance.earnings?.grossVolumeUsd), financeDays); const revenuePerMonth = commissionPerMonth; const revenueWindow = (revenuePerMonth * days) / 30; @@ -337,8 +443,18 @@ export function buildRoi(input: { "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.`); + if (!haveSeries) { + caveats.push( + `No day series from CoinPay, so revenue is a lifetime total rescaled from ${financeDays}d and is probably far too high.`, + ); + } + // The gap between the two is the whole reason the series is used. Naming it + // keeps the big number visible without letting it be read as a rate. + const lifetimeCommission = n(finance.earnings?.commissionUsd); + if (haveSeries && lifetimeCommission > commissionPerMonth * 2) { + caveats.push( + `Lifetime commission is ${lifetimeCommission.toFixed(2)} against ${commissionPerMonth.toFixed(2)} in the last ${seriesDays}d. The rate here is the recent one; the lifetime figure is not a run rate.`, + ); } const ledgerRows = finance.bank?.ledger?.length ?? 0; const ledgerTotal = n(finance.bank?.ledgerTotal); @@ -358,12 +474,17 @@ export function buildRoi(input: { allScopesPerMonthUsd: burn.allScopesPerMonthUsd, vendors: vendorSpend(finance), vendorsPartial, + lookbackDays: n(finance.position?.lookbackDays), }, revenue: { perMonthUsd: revenuePerMonth, windowUsd: revenueWindow, commissionPerMonthUsd: commissionPerMonth, grossVolumePerMonthUsd: grossPerMonth, + observedDays: haveSeries ? seriesDays : financeDays, + lifetimeCommissionUsd: n(finance.earnings?.commissionUsd), + lifetimeGrossVolumeUsd: n(finance.earnings?.grossVolumeUsd), + estimated: !haveSeries, }, internal: { adSpendUsd, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 44cfeb4..4242127 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -53,6 +53,11 @@ function fromConfig(file: string, field: string): string | null { const home = () => process.env.HOME ?? process.env.USERPROFILE ?? ""; +const num = (v: unknown): number => { + const x = Number(v); + return Number.isFinite(x) ? x : 0; +}; + export function apiToken(args: Args): string | null { const direct = (args.flags.token as string | undefined) ?? process.env.CRAWLPROOF_TOKEN; if (direct && direct.trim()) return direct.trim(); @@ -104,6 +109,20 @@ COMMANDS to the last day and humans only, because a launch is invisible inside a month of crawler traffic. With one project the site can be left out. + ad [--name=N] [--budget=CENTS] [--bid=CREDITS] [--draft] [--json] + Run an ad for a URL. CrawlProof reads the page, writes the creatives and + starts serving. A URL that already has a live campaign gets that campaign + back, so running it twice is safe. + + Ad network targets on the Ads screen default to 3,000,000 impressions a + month at 5% CTR. Override with --target-impressions=N and --target-ctr=5. + + ads [list] [--limit=20] [--json] + ads show|pause|resume + ads budget + ads delete --yes + Look at and change what is running. A ref looks like crawlproof-ad-144. + help | version AUTH @@ -187,6 +206,11 @@ async function cmdDashboard(args: Args): Promise { token, range, who, + ...(Number(args.flags["target-impressions"]) > 0 + ? { targetImpressions: Number(args.flags["target-impressions"]) } + : {}), + // Given as a percentage, kept as a fraction: nobody types 0.05 for 5%. + ...(Number(args.flags["target-ctr"]) > 0 ? { targetCtr: Number(args.flags["target-ctr"]) / 100 } : {}), interval: Number(args.flags.interval) || 60, concurrency: Number(args.flags.concurrency) || 8, coinpay, @@ -196,6 +220,160 @@ async function cmdDashboard(args: Args): Promise { return 0; } + +/** One authenticated call against the CrawlProof API. */ +async function apiCall( + args: Args, + method: "GET" | "POST" | "PATCH" | "DELETE", + path: string, + body?: Record, +): Promise<{ status: number; json: Record }> { + const token = apiToken(args); + if (!token) return { status: 401, json: { error: "no API token" } }; + const res = await fetch(`${apiBase(args)}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...(body ? { "content-type": "application/json" } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + const text = await res.text(); + let json: Record = {}; + try { + json = text ? (JSON.parse(text) as Record) : {}; + } catch { + json = { error: `${res.status} ${res.statusText}: not JSON` }; + } + return { status: res.status, json }; +} + +/** The body `crawlproof ad` sends. Pure, so the flag handling is testable. */ +export function campaignBody(url: string, args: Args): Record { + const body: Record = { url }; + if (typeof args.flags.name === "string") body.name = args.flags.name; + if (typeof args.flags.budget === "string") body.daily_budget_cents = Number(args.flags.budget); + if (typeof args.flags.bid === "string") body.bid_credits = Number(args.flags.bid); + // Active unless asked otherwise: `ad ` is a verb, and a campaign that + // does not run is not what the word means. + body.status = args.flags.draft ? "draft" : "active"; + return body; +} + +/** + * `crawlproof ad ` — read the page, write the creatives, start serving. + * + * The whole point is that it is one word and one URL. A URL that already has a + * live campaign gets that campaign back rather than a twin, so running it twice + * is safe. + */ +async function cmdAd(args: Args): Promise { + const url = args.positional[0]; + if (!url) { + console.error("usage: crawlproof ad [--name=N] [--budget=CENTS] [--bid=CREDITS] [--draft] [--json]"); + return 2; + } + const { status, json } = await apiCall(args, "POST", "/api/ads/v1/campaigns", campaignBody(url, args)); + if (status >= 400) { + console.error(`ad failed: ${status} ${json.error ?? ""}`); + return 1; + } + if (args.flags.json) { + process.stdout.write(`${JSON.stringify(json, null, 2)}\n`); + return 0; + } + const existing = json.existing ? " (already running)" : ""; + process.stdout.write( + `${json.status} ${json.ref_slug} ${json.name}${existing}\n ${json.destination_url}\n ${json.dashboard_url ?? ""}\n`, + ); + return 0; +} + +async function cmdAds(args: Args): Promise { + const sub = args.positional[0] ?? "list"; + + if (sub === "list") { + const limit = (args.flags.limit as string | undefined) ?? "20"; + const { status, json } = await apiCall(args, "GET", `/api/ads/v1/campaigns?limit=${encodeURIComponent(limit)}`); + if (status >= 400) { + console.error(`ads list failed: ${status} ${json.error ?? ""}`); + return 1; + } + const campaigns = (json.campaigns as Record[]) ?? []; + if (args.flags.json) { + process.stdout.write(`${JSON.stringify(campaigns, null, 2)}\n`); + return 0; + } + if (!campaigns.length) { + process.stdout.write("No campaigns yet. `crawlproof ad ` starts one.\n"); + return 0; + } + for (const c of campaigns) { + process.stdout.write(`${String(c.status).padEnd(9)} ${c.ref_slug} ${c.name}\n`); + } + return 0; + } + + const ref = args.positional[1]; + if (!["show", "pause", "resume", "budget", "delete"].includes(sub)) { + console.error(`unknown: crawlproof ads ${sub}`); + return 2; + } + if (!ref) { + console.error(`usage: crawlproof ads ${sub} ${sub === "budget" ? " " : ""}`); + return 2; + } + + const path = `/api/ads/v1/campaigns/${encodeURIComponent(ref)}`; + let method: "GET" | "PATCH" | "DELETE" = "GET"; + let body: Record | undefined; + if (sub === "pause") ((method = "PATCH"), (body = { status: "paused" })); + if (sub === "resume") ((method = "PATCH"), (body = { status: "active" })); + if (sub === "budget") { + const cents = Number(args.positional[2]); + if (!Number.isInteger(cents) || cents < 0) { + console.error("usage: crawlproof ads budget "); + return 2; + } + ((method = "PATCH"), (body = { daily_budget_cents: cents })); + } + if (sub === "delete") { + if (!args.flags.yes) { + console.error("delete removes the campaign and its metering; pass --yes. Pause keeps the history."); + return 2; + } + method = "DELETE"; + } + + const { status, json } = await apiCall(args, method, path, body); + if (status >= 400) { + console.error(`ads ${sub} failed: ${status} ${json.error ?? ""}`); + return 1; + } + if (args.flags.json) { + process.stdout.write(`${JSON.stringify(json, null, 2)}\n`); + return 0; + } + if (sub === "delete") { + process.stdout.write(`deleted ${json.deleted}\n`); + return 0; + } + const stats = json.stats as Record | undefined; + process.stdout.write(`${json.status} ${json.ref_slug} ${json.name}\n ${json.destination_url}\n`); + if (stats) { + // Free delivery first: on a network running entirely on free backfill the + // paid columns are zero, and leading with them reads as "nothing happened". + const visits = stats.visits as { total?: number } | undefined; + process.stdout.write( + ` ${num(stats.free_impressions) + num(stats.impressions)} impressions (${num(stats.free_impressions)} free) · ` + + `${num(stats.free_clicks) + num(stats.clicks)} clicks (${num(stats.free_clicks)} free) · ` + + `${num(stats.spent_cents)}\u00a2 spent · ${visits?.total ?? 0} visits attributed\n`, + ); + } + return 0; +} + export async function main(argv: string[]): Promise { const args = parseArgs(argv); try { @@ -206,6 +384,10 @@ export async function main(argv: string[]): Promise { return await cmdDashboard(args); case "stats": return await cmdStats(args); + case "ad": + return await cmdAd(args); + case "ads": + return await cmdAds(args); case "version": case "--version": case "-v": diff --git a/tests/dashboard-roi.test.ts b/tests/dashboard-roi.test.ts index f05b0e7..31ac7ea 100644 --- a/tests/dashboard-roi.test.ts +++ b/tests/dashboard-roi.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + adTargets, businessAccountIds, businessBurn, buildRoi, @@ -17,7 +18,16 @@ import { const finance = (): FinanceInput => ({ windowDays: 30, - earnings: { commissionUsd: 120, grossVolumeUsd: 12_000, netUsd: 11_880 }, + // Lifetime, and deliberately far above the series below: this is the shape + // production actually returns, and the gap is what used to be mistaken for + // a run rate. + earnings: { commissionUsd: 5_000, grossVolumeUsd: 500_000, netUsd: 495_000 }, + // 30 points summing to 120 commission and 12,000 volume: the real rate. + series: Array.from({ length: 30 }, (_, i) => ({ + label: `2026-08-${String(i + 1).padStart(2, "0")}`, + volumeUsd: 400, + commissionUsd: 4, + })), position: { lookbackDays: 180, monthsObserved: 4, @@ -134,6 +144,52 @@ describe("vendors", () => { }); }); +describe("adTargets", () => { + const delivered = { + totals: { + pubImpressions: 220_000, + pubPaidImpressions: 17_000, + pubFreeImpressions: 203_000, + pubClicks: 80, + invalidClicks: 9_700, + spentCents: 1_500, + }, + }; + + it("counts free delivery as delivery, because that is what the network runs on", () => { + const t = adTargets(delivered); + expect(t.impressions).toBe(220_000); + expect(t.freeImpressions).toBe(203_000); + expect(t.paidImpressions).toBe(17_000); + }); + + it("measures progress against the target rather than reporting a bare total", () => { + const t = adTargets(delivered); + expect(t.impressionProgress).toBeCloseTo(220_000 / 3_000_000); + expect(t.ctr).toBeCloseTo(80 / 220_000); + expect(t.ctrProgress).toBeCloseTo(80 / 220_000 / 0.05); + }); + + it("projects revenue at target from the price actually charged", () => { + const t = adTargets(delivered); + // 1,500c over 80 valid clicks. + expect(t.cpcCents).toBeCloseTo(18.75); + expect(t.projectedMonthlyUsd).toBeCloseTo((3_000_000 * 0.05 * 18.75) / 100); + }); + + it("refuses to project from a price nothing was ever sold at", () => { + const t = adTargets({ totals: { pubImpressions: 100, pubClicks: 1, spentCents: 0 } }); + expect(t.cpcCents).toBeNull(); + expect(t.projectedMonthlyUsd).toBeNull(); + }); + + it("takes overridden targets", () => { + const t = adTargets(delivered, { targetImpressions: 1_000_000, targetCtr: 0.06 }); + expect(t.impressionProgress).toBeCloseTo(0.22); + expect(t.targetCtr).toBe(0.06); + }); +}); + describe("buildRoi", () => { it("never counts self-deal ad money as revenue or as cost", () => { const model = buildRoi({ traffic: traffic(), ads: ads(), finance: finance() }); @@ -147,6 +203,44 @@ describe("buildRoi", () => { expect(model.caveats.some((c) => c.includes("both sides of the network"))).toBe(true); }); + it("builds revenue from the day series, never from the lifetime headline", () => { + // The regression this pins: `earnings` does not move when the window + // changes, so it is lifetime. Rescaling it turned a dead merchant's + // historical volume into a six-figure monthly run rate in production. + const model = buildRoi({ traffic: traffic(), ads: ads(), finance: finance() }); + expect(model.revenue.commissionPerMonthUsd).toBeCloseTo(120); + expect(model.revenue.grossVolumePerMonthUsd).toBeCloseTo(12_000); + expect(model.revenue.lifetimeCommissionUsd).toBe(5_000); + expect(model.revenue.observedDays).toBe(30); + expect(model.revenue.estimated).toBe(false); + expect(model.caveats.some((c) => c.includes("not a run rate"))).toBe(true); + }); + + it("does not change the revenue rate when the traffic window changes", () => { + const month = buildRoi({ traffic: traffic(), ads: ads(), finance: finance() }); + const hour = buildRoi({ + traffic: { range: "1h", who: "humans", sites: [{ site: "a.com", visitors: 1, pageviews: 1 }] }, + ads: ads(), + finance: { ...finance(), windowDays: 7 }, + }); + // Same underlying series, so the same rate. Before the fix a 7 day window + // multiplied it by 30/7. + expect(hour.revenue.perMonthUsd).toBeCloseTo(month.revenue.perMonthUsd); + }); + + it("says so loudly when there is no series to build a rate from", () => { + const f = finance(); + delete f.series; + const model = buildRoi({ traffic: traffic(), ads: ads(), finance: f }); + expect(model.revenue.estimated).toBe(true); + expect(model.caveats.some((c) => c.includes("probably far too high"))).toBe(true); + }); + + it("names the window the burn is averaged over", () => { + const model = buildRoi({ traffic: traffic(), ads: ads(), finance: finance() }); + expect(model.cost.lookbackDays).toBe(180); + }); + 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);