From 60ad8b33d29dd7b976d18c9269cc006668811b35 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 12:28:52 +0000 Subject: [PATCH] Revenue was a lifetime total pretending to be a rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard reported $131/mo of revenue and, on a 1d window, a merchant volume of $126.8K/mo. Neither was real. CoinPay's `earnings` block does not move when the window changes. Asked for 7 days and for 30 it returns byte-identical grossVolumeUsd, commissionUsd and transaction counts, while the bank half of the same response does change. So it is a balance, not a rate, and dividing it by a window it never covered is how a banned merchant's historical card volume became a six-figure run rate. Revenue now comes from the day series, which is windowed. Against production that moves commission from $131.06/mo to $3.82/mo and gross volume from $29,591/mo to $653/mo, and the figure stops changing when you change the traffic range. The lifetime totals are still shown, underneath and labelled, so the big number is visible without being read as income. Delivery was zero everywhere for a different reason, and also mine. The windowed delivery RPCs are `security definer` and filter on `auth.uid()`, which is NULL for the service client this route passes, so they returned nothing and every impression count arrived as a confident zero. The stats views are `security_invoker` and granted to service_role, so they can be read directly. Reading them in chunks of 50 matters: `in.(…)` for 180-odd campaign uuids is a 7KB query string that comes back empty rather than erroring, which looks exactly like a network with no delivery. Production actually has 220,154 impressions, 202,848 of them free. The views do not mean what their column names suggest. Impressions split paid/free and add up; `clicks` is already every valid click and `free_clicks` is invalid free clicks. Adding those in would fold click fraud into the CTR. The Ads screen led with paid delivery on a network that runs entirely on free backfill, which reported a working network as a dead one. It now leads with free, and measures progress toward 3,000,000 impressions a month at 5% CTR (--target-impressions, --target-ctr), projecting what a month at target earns at the price actually charged. Where nothing has ever been charged it says so rather than projecting from an invented price. And every panel that mixes time bases now names its own: the bank window is not the traffic range, the burn is a 180 day average, the ad figures are lifetime. `crawlproof ad ` runs an ad from the box, with the rest of `ads` behind it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HvWJ4336pxTFRdRbvsTQeD --- app/api/ads/v1/earnings/route.ts | 100 ++++++++++++++++- cli/dashboard.ts | 145 +++++++++++++++++------- lib/dashboard/roi.ts | 129 +++++++++++++++++++++- packages/cli/src/cli.ts | 182 +++++++++++++++++++++++++++++++ tests/dashboard-roi.test.ts | 96 +++++++++++++++- 5 files changed, 603 insertions(+), 49 deletions(-) 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);