From 0f7eed18b6fcc89da28dcfce36a9592f12941dc9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 1 Sep 2026 14:50:09 +0000 Subject: [PATCH] Count free-tier delivery on the earnings and slots pages too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earnings page and the slots page read `impressions` and `clicks` off ad_slot_stats / ad_campaign_stats. Those are the tier-'paid' halves of the views; the free halves sit in `free_impressions` / `free_clicks`, which neither page ever selected. Once every slot and every campaign belonged to one account, ad_charge_click took its self-deal branch on every fill and paid delivery stopped: 17,303 paid impressions in July, 3 in August, 0 in September. So both pages have read zero ever since, while the campaigns dashboard — which sums both halves after #199 — showed six figures over the same window. rssamplifier.com delivered 116,071 impressions and its row said 0. #199 fixed this measure on the campaigns dashboard and did not reach here. The same views are also lifetime, with no window at all, while the page and the PDF header both promise "last N days" — so the figures were wrong twice over: the wrong tier, for the wrong period. loadEarnings now reads ad_campaign_totals and a new ad_slot_totals, both windowed and both returning each tier, and the window is the same whole-UTC-day span the chart above the tables already drew. Money stays lifetime on purpose, and the page now says so. "Available to withdraw" is lifetime earnings minus lifetime payouts; scoping either side to 30 days would under-report a balance the account is actually owed. The tiles are grouped under "Balance · all time" and the tables under "Delivery · last 30 days" rather than one heading claiming a period for both. Invalid clicks were the third gap. A click we refuse to bill is recorded with valid = false, and resolveClick's insert left `tier` at its 'paid' default, so the row matched neither the billed bucket (valid) nor the free bucket (not valid and tier = 'free'). 57,060 clicks had collected there, visible to nothing. They stay out of the delivery figures deliberately — a bot click is not delivery, and folding it in would put a 16% CTR on the page — but ad_slot_totals returns the count and the page reports it in a line of its own. The insert now writes `tier` explicitly, so the bucket is a decision rather than a default. Verified against prod: ad_slot_totals over the last 30 days returns 174,959 impressions / 5,550 clicks / 57,063 invalid, matching a raw count over ad_impressions and ad_clicks exactly, in ~360ms against an 8s statement timeout. The migration adds a function and alters nothing, so it is already applied. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0147H2VoYJS2WUz4JQmaLQKV --- app/(app)/dashboard/ads/earnings/page.tsx | 75 ++++++++- app/(app)/dashboard/ads/slots/page.tsx | 31 +++- lib/ads/earnings-data.ts | 79 ++++++--- lib/ads/series.ts | 103 +++++++++++- lib/ads/serve.ts | 10 ++ .../20260901120000_ad_slot_totals.sql | 93 +++++++++++ tests/ads-earnings-free-tier.test.ts | 150 ++++++++++++++++++ 7 files changed, 503 insertions(+), 38 deletions(-) create mode 100644 supabase/migrations/20260901120000_ad_slot_totals.sql create mode 100644 tests/ads-earnings-free-tier.test.ts diff --git a/app/(app)/dashboard/ads/earnings/page.tsx b/app/(app)/dashboard/ads/earnings/page.tsx index 6e4ff4d2..aa576ca7 100644 --- a/app/(app)/dashboard/ads/earnings/page.tsx +++ b/app/(app)/dashboard/ads/earnings/page.tsx @@ -29,6 +29,7 @@ const EMPTY: EarningsModel = { advClicks: 0, pubImpressions: 0, pubClicks: 0, + invalidClicks: 0, }, campaigns: [], slots: [], @@ -45,6 +46,18 @@ export default async function EarningsPage() { const model = user ? await loadEarnings(supabase, user.id, RANGE) : EMPTY; const t = model.totals; + // Money that moved *in the window*, which the balance tiles cannot answer — + // they are lifetime. Delivery with no money behind it in the same window is + // what the free-tier note explains, and this is the only figure that tells + // the two apart. + const rangeEarnedCents = model.slots.reduce((a, s) => a + s.earnedCents, 0); + const rangeSpentCents = model.campaigns.reduce((a, c) => a + c.spentCents, 0); + const deliveredInRange = t.pubImpressions > 0 || t.advImpressions > 0; + const invalidNote = + t.invalidClicks === 1 + ? "1 further click was filtered as invalid" + : `${t.invalidClicks.toLocaleString()} further clicks were filtered as invalid`; + return (
@@ -56,28 +69,74 @@ export default async function EarningsPage() {

Your CrawlProof ad money across both sides — what you earn as a publisher and what - you spend as an advertiser. Last {RANGE} days. Download a PDF report for your - accountant or team. + you spend as an advertiser. Download a PDF report for your accountant or team.

-
+ {/* Balances, not a period. "Available to withdraw" is lifetime earnings + minus lifetime payouts; clipping it to the last {RANGE} days would + under-report money the account is owed. The tables below are the ones + that cover a window, and they say so. */} +

+ Balance · all time +

+
= 0} danger={t.netCents < 0} />
-
- -
- -
+
+ {/* Everything below covers the window, and the heading is the only place + that has to say so. */} +

+ Delivery · last {RANGE} days +

+ +
+ +
+ +
+ + + + +
+ + {/* Delivery counts free backfill, so a range that earned nothing still + reports the traffic it carried. Without this line the tables read as a + billing fault rather than as the free tier working. */} + {deliveredInRange && rangeEarnedCents === 0 && rangeSpentCents === 0 && ( +

+ Impressions and clicks count free-tier backfill as well as paid inventory — a + campaign out of credits or daily budget, or one running on a slot its own + account owns. It bills nobody and earns nobody, which is why delivery can be + busy while the money is flat. +

+ )} + + {/* Otherwise these are recorded and shown nowhere, and on a site under a + bot run they are most of the click volume. */} + {t.invalidClicks > 0 && ( +

+ {invalidNote} — bot, duplicate, forged, or against a campaign that was not + servable — and left out of the figures above. +

+ )} + {/* Publisher earnings */}

Earnings by site

{model.slots.length === 0 ? ( diff --git a/app/(app)/dashboard/ads/slots/page.tsx b/app/(app)/dashboard/ads/slots/page.tsx index 14ee88b4..2b1ff73c 100644 --- a/app/(app)/dashboard/ads/slots/page.tsx +++ b/app/(app)/dashboard/ads/slots/page.tsx @@ -3,6 +3,12 @@ import { createClient } from "@/lib/supabase/server"; import { env } from "@/lib/env"; import { fetchSupportedTokens } from "@/lib/coinpay-tokens"; import { SlotManager } from "@/components/ads/slot-manager"; +import { + deliveredClicks, + deliveredImpressions, + getSlotTotalsSince, + type SlotTotals, +} from "@/lib/ads/series"; // Fallback coins when CoinPay's supported-coins endpoint is unavailable, so the // payout dropdown is never empty. Codes match what /payments+/payouts expect. @@ -34,7 +40,6 @@ type Payout = { tx_hash: string | null; created_at: string; }; -type SlotStat = { slot_id: string; impressions: number; clicks: number; earned_cents: number }; export default async function SlotsPage() { const supabase = await createClient(); @@ -47,9 +52,9 @@ export default async function SlotsPage() { const earnedBySlot = new Map(); const withdrawnBySlot = new Map(); const payoutsBySlot = new Map(); - const statsBySlot = new Map(); + let statsBySlot = new Map(); if (user) { - const [{ data: p }, { data: s }, { data: ledger }, { data: payouts }, { data: slotStats }] = + const [{ data: p }, { data: s }, { data: ledger }, { data: payouts }, slotStats] = await Promise.all([ // Monetization is owner-only: you earn from a slot, so only list projects // you OWN — not org/member-shared ones the broad RLS would also return. @@ -64,11 +69,15 @@ export default async function SlotsPage() { .from("ad_payouts") .select("id, slot_id, amount_cents, currency, status, tx_hash, created_at") .order("created_at", { ascending: false }), - supabase.from("ad_slot_stats").select("slot_id, impressions, clicks, earned_cents"), + // Lifetime (null window), like the payout figures beside it — but via + // the RPC rather than the ad_slot_stats view, because the view counts + // only tier 'paid' and every fill here is free backfill, so every site + // read 0 impressions while serving six figures of them. + getSlotTotalsSince(supabase, null), ]); projects = (p as Project[]) ?? []; slots = (s as Slot[]) ?? []; - for (const st of (slotStats as SlotStat[]) ?? []) statsBySlot.set(st.slot_id, st); + statsBySlot = slotStats; for (const row of (ledger as { slot_id: string; amount_cents: number }[]) ?? []) { if (row.slot_id) earnedBySlot.set(row.slot_id, (earnedBySlot.get(row.slot_id) ?? 0) + (row.amount_cents ?? 0)); } @@ -139,7 +148,17 @@ export default async function SlotsPage() { availableCents={earned - withdrawn} coins={coins} payouts={slot ? payoutsBySlot.get(slot.id) ?? [] : []} - stats={stat ? { impressions: stat.impressions, clicks: stat.clicks, earnedCents: stat.earned_cents } : null} + stats={ + stat + ? { + // Paid inventory plus free backfill: what the site + // actually showed, not just what someone paid for. + impressions: deliveredImpressions(stat), + clicks: deliveredClicks(stat), + earnedCents: stat.earnedCents, + } + : null + } /> ); })} diff --git a/lib/ads/earnings-data.ts b/lib/ads/earnings-data.ts index 228f5399..ebea2502 100644 --- a/lib/ads/earnings-data.ts +++ b/lib/ads/earnings-data.ts @@ -1,8 +1,15 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { + deliveredClicks, + deliveredImpressions, getCampaignDailySeries, + getCampaignTotalsSince, getSlotDailySeries, + getSlotTotalsSince, mergeMoneySeries, + sinceForDays, + EMPTY_SLOT_TOTALS, + EMPTY_TOTALS, } from "./series"; // Unified money model for one account — a single user is both an advertiser @@ -40,6 +47,19 @@ export type EarningsPayoutRow = { export type EarningsModel = { rangeDays: number; + /** + * Money is a balance and delivery is a rate, so they answer different + * questions and cannot share a window. + * + * `spentCents` / `earnedCents` / `withdrawnCents` / `availableCents` / + * `netCents` are ALL TIME: "available to withdraw" is lifetime earnings minus + * lifetime payouts, and clipping it to the last 30 days would under-report a + * real balance the account is owed. + * + * Everything else — the impression and click totals, and every row in + * `campaigns` and `slots` — covers `rangeDays`, which is what the page and + * the PDF header both promise. + */ totals: { spentCents: number; earnedCents: number; @@ -52,6 +72,13 @@ export type EarningsModel = { advClicks: number; pubImpressions: number; pubClicks: number; + /** + * Clicks recorded in the range and deliberately not counted as delivery: + * bot, duplicate, forged, or against a campaign that was not servable. + * Reported so they are visible somewhere; kept out of pubClicks so a bot + * run cannot flatter the CTR. + */ + invalidClicks: number; }; campaigns: EarningsCampaignRow[]; slots: EarningsSlotRow[]; @@ -67,10 +94,8 @@ type CampaignRow = { spend_today_cents: number | null; spend_date: string | null; }; -type CampaignStat = { campaign_id: string; impressions: number; clicks: number; spent_cents: number }; type Project = { id: string; name: string }; type Slot = { id: string; project_id: string; status: string }; -type SlotStat = { slot_id: string; impressions: number; clicks: number; earned_cents: number }; type LedgerRow = { slot_id: string | null; amount_cents: number | null }; type PayoutRow = { amount_cents: number | null; @@ -85,21 +110,29 @@ export async function loadEarnings( userId: string, days = 30, ): Promise { + // The window the tables and the impression/click totals cover. Money is not + // scoped to it — see the note on EarningsModel.totals. + const since = sinceForDays(days); + const [ { data: campaignsData }, - { data: campaignStatsData }, + campaignTotals, { data: projectsData }, { data: slotsData }, - { data: slotStatsData }, + slotTotals, { 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_campaign_stats").select("campaign_id, impressions, clicks, spent_cents"), + // 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 + // return both tiers. + 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_slot_stats").select("slot_id, impressions, clicks, earned_cents"), + getSlotTotalsSince(supabase, since), supabase.from("ad_ledger").select("slot_id, amount_cents").eq("kind", "publisher_accrual"), supabase .from("ad_payouts") @@ -108,14 +141,10 @@ export async function loadEarnings( ]); const campaigns = (campaignsData as CampaignRow[]) ?? []; - const campaignStats = new Map(); - for (const s of (campaignStatsData as CampaignStat[]) ?? []) campaignStats.set(s.campaign_id, s); const projectsById = new Map(); for (const p of (projectsData as Project[]) ?? []) projectsById.set(p.id, p); // Only slots for projects the user owns (mirrors the /ads/slots scoping). const slots = ((slotsData as Slot[]) ?? []).filter((s) => projectsById.has(s.project_id)); - const slotStats = new Map(); - for (const s of (slotStatsData as SlotStat[]) ?? []) slotStats.set(s.slot_id, s); const earnedBySlot = new Map(); for (const row of (ledgerData as LedgerRow[]) ?? []) { if (row.slot_id) earnedBySlot.set(row.slot_id, (earnedBySlot.get(row.slot_id) ?? 0) + (row.amount_cents ?? 0)); @@ -124,31 +153,39 @@ export async function loadEarnings( const todayUtc = new Date().toISOString().slice(0, 10); + // Delivery is paid inventory plus free backfill, the same measure the + // campaigns dashboard reports. A free-tier impression is still an impression; + // what it is not is revenue, and the money columns say so on their own. const campaignRows: EarningsCampaignRow[] = campaigns.map((c) => { - const s = campaignStats.get(c.id); + const s = campaignTotals.get(c.id) ?? EMPTY_TOTALS; return { id: c.id, name: c.name, status: c.status, - impressions: s?.impressions ?? 0, - clicks: s?.clicks ?? 0, - spentCents: c.total_spent_cents ?? 0, + impressions: deliveredImpressions(s), + clicks: deliveredClicks(s), + spentCents: s.spentCents, }; }); const slotRows: EarningsSlotRow[] = slots.map((sl) => { - const s = slotStats.get(sl.id); + const s = slotTotals.get(sl.id) ?? EMPTY_SLOT_TOTALS; return { id: sl.id, name: projectsById.get(sl.project_id)?.name ?? "Site", status: sl.status, - impressions: s?.impressions ?? 0, - clicks: s?.clicks ?? 0, - earnedCents: earnedBySlot.get(sl.id) ?? 0, + impressions: deliveredImpressions(s), + clicks: deliveredClicks(s), + earnedCents: s.earnedCents, }; }); - const spentCents = campaignRows.reduce((a, c) => a + c.spentCents, 0); + // Lifetime, deliberately — these feed the balance tiles. `availableCents` is + // lifetime earnings minus lifetime payouts, so scoping either side to the + // range would under-report money the account is actually owed. Read them from + // the campaign row and the ledger rather than from campaignRows/slotRows, + // whose money columns now cover the range instead. + const spentCents = campaigns.reduce((a, c) => a + (c.total_spent_cents ?? 0), 0); const earnedCents = [...earnedBySlot.values()].reduce((a, v) => a + v, 0); const withdrawnCents = payouts .filter((p) => p.status !== "failed") @@ -179,6 +216,10 @@ export async function loadEarnings( advClicks: campaignRows.reduce((a, c) => a + c.clicks, 0), pubImpressions: slotRows.reduce((a, s) => a + s.impressions, 0), pubClicks: slotRows.reduce((a, s) => a + s.clicks, 0), + invalidClicks: slots.reduce( + (a, sl) => a + (slotTotals.get(sl.id)?.invalidClicks ?? 0), + 0, + ), }, campaigns: campaignRows, slots: slotRows, diff --git a/lib/ads/series.ts b/lib/ads/series.ts index 2e36ba58..38fb3380 100644 --- a/lib/ads/series.ts +++ b/lib/ads/series.ts @@ -125,12 +125,15 @@ export function sumSeries(points: AccountPoint[]): RangeTotals { * impressions. A free-tier impression is still an impression; what it isn't is * revenue, and Spend is the tile that says so. */ -export function deliveredImpressions(t: RangeTotals): number { +export function deliveredImpressions(t: { + impressions: number; + freeImpressions: number; +}): number { return t.impressions + t.freeImpressions; } /** Clicks actually taken in the range: billed plus unbillable-but-real. */ -export function deliveredClicks(t: RangeTotals): number { +export function deliveredClicks(t: { clicks: number; freeClicks: number }): number { return t.clicks + t.freeClicks; } @@ -165,11 +168,23 @@ export async function getCampaignRangeTotals( supabase: SupabaseClient, range: RangeDef, now: Date = new Date(), +): Promise> { + return getCampaignTotalsSince(supabase, rangeSince(range, now)); +} + +/** + * Per-campaign totals from an explicit window start (null = all time). + * + * Split out from getCampaignRangeTotals because the earnings page and the PDF + * report think in calendar days rather than in chart ranges, and both need the + * same free-tier-aware figures the campaigns dashboard already gets. + */ +export async function getCampaignTotalsSince( + supabase: SupabaseClient, + since: string | null, ): Promise> { const out = new Map(); - const { data, error } = await supabase.rpc("ad_campaign_totals", { - p_since: rangeSince(range, now), - }); + const { data, error } = await supabase.rpc("ad_campaign_totals", { p_since: since }); if (error) return out; for (const row of (data as CampaignTotalsRow[]) ?? []) { @@ -184,6 +199,84 @@ export async function getCampaignRangeTotals( return out; } +/** + * Publisher-side totals for one slot over a window. + * + * Same paid/free split as RangeTotals, but the money runs the other way + * (earned, not spent) and there is one extra bucket: invalidClicks, the clicks + * we recorded and refused to count as delivery. They are reported on their own + * rather than added to freeClicks — a bot or duplicate click is not delivery, + * and folding it in would inflate every CTR on the earnings page. + */ +export type SlotTotals = { + impressions: number; + freeImpressions: number; + clicks: number; + freeClicks: number; + invalidClicks: number; + earnedCents: number; +}; + +export const EMPTY_SLOT_TOTALS: SlotTotals = { + impressions: 0, + freeImpressions: 0, + clicks: 0, + freeClicks: 0, + invalidClicks: 0, + earnedCents: 0, +}; + +type SlotTotalsRow = { + slot_id: string; + impressions: number | string; + free_impressions: number | string; + clicks: number | string; + free_clicks: number | string; + invalid_clicks: number | string; + earned_cents: number | string; +}; + +/** + * Per-slot totals from an explicit window start (null = all time). + * + * The publisher-side twin of getCampaignTotalsSince. Reads the ad_slot_totals + * RPC rather than the ad_slot_stats view: the view is lifetime and counts only + * tier 'paid', so on a network where every fill is free backfill it reports + * zero for every site. See migration 20260901120000_ad_slot_totals.sql. + */ +export async function getSlotTotalsSince( + supabase: SupabaseClient, + since: string | null, +): Promise> { + const out = new Map(); + const { data, error } = await supabase.rpc("ad_slot_totals", { p_since: since }); + if (error) return out; + + for (const row of (data as SlotTotalsRow[]) ?? []) { + out.set(row.slot_id, { + impressions: Number(row.impressions) || 0, + freeImpressions: Number(row.free_impressions) || 0, + clicks: Number(row.clicks) || 0, + freeClicks: Number(row.free_clicks) || 0, + invalidClicks: Number(row.invalid_clicks) || 0, + earnedCents: Number(row.earned_cents) || 0, + }); + } + return out; +} + +/** + * Start of a window `days` UTC calendar days long, ending today — the same + * window dayAxis() draws and the same one ad_campaign_daily_series filters on, + * so a table total always agrees with the chart above it. + */ +export function sinceForDays(days: number, now: Date = new Date()): string { + const n = Math.max(1, Math.floor(days)); + return new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (n - 1)), + ).toISOString(); +} + export type CampaignDailyPoint = { /** UTC calendar day, YYYY-MM-DD */ date: string; diff --git a/lib/ads/serve.ts b/lib/ads/serve.ts index 9df1a0c0..5eaeddff 100644 --- a/lib/ads/serve.ts +++ b/lib/ads/serve.ts @@ -525,6 +525,15 @@ export async function resolveClick(input: { } else { // Invalid (bot / duplicate / forged): record an unbilled click for // analytics, charge nobody. + // + // `tier` is written explicitly even though 'paid' is the column default, + // because which bucket this row lands in is a decision, not a default. + // Reporting counts billed clicks as `valid` and unbillable-but-real ones + // as `not valid and tier = 'free'`; an invalid click is neither, and + // moving it into the free bucket to make it visible would fold bot + // traffic into delivery and inflate every CTR on the dashboard. It stays + // out, and ad_slot_totals reports the count separately as invalid_clicks + // so it is still visible somewhere. await sb.from("ad_clicks").insert({ impression_id: input.impressionId ?? null, slot_id: input.slotId, @@ -538,6 +547,7 @@ export async function resolveClick(input: { publisher_earn_cents: 0, platform_cut_cents: 0, valid: false, + tier: "paid", }); } } diff --git a/supabase/migrations/20260901120000_ad_slot_totals.sql b/supabase/migrations/20260901120000_ad_slot_totals.sql new file mode 100644 index 00000000..5f3fce5d --- /dev/null +++ b/supabase/migrations/20260901120000_ad_slot_totals.sql @@ -0,0 +1,93 @@ +-- Ad network: publisher-side totals that count free-tier delivery, over a window. +-- +-- ad_slot_stats / ad_campaign_stats split delivery in two — `impressions` is +-- tier 'paid' only, `free_impressions` is tier 'free' — and the earnings page +-- and the slots page each read the paid half and never the free one. Every fill +-- on this network has been free tier since the self-deal demotion landed +-- (one account owns both the campaigns and the slots, so ad_charge_click takes +-- its self-deal branch every time), which is why both pages have read zero for +-- months while the campaigns dashboard, which sums both halves, showed six +-- figures. rssamplifier.com: 116,071 impressions delivered, 0 on the page. +-- +-- The views are also lifetime — no window at all — while the earnings page and +-- the PDF report both promise "last N days". So the figures were wrong twice +-- over: the wrong tier, for the wrong period. +-- +-- ad_campaign_totals already solves exactly this on the advertiser side. This is +-- its publisher-side twin, same shape and same rules: +-- * security invoker plus an explicit owner_id = auth.uid() scope, so the +-- publisher-side read grants on ad_impressions cannot leak another +-- account's slots in. +-- * one row per slot, never (slots x buckets), so it cannot hit PostgREST's +-- 1000-row response cap. See 20260731140000_ad_range_series.sql. +-- +-- invalid_clicks has no equivalent on the advertiser side and is the point of +-- the new column. A click we refuse to bill is recorded with valid = false, and +-- resolveClick's insert leaves tier at its 'paid' default, so the row matches +-- neither the billed bucket (valid) nor the free bucket (not valid and tier = +-- 'free'). 57,060 clicks have accumulated in that gap, visible to nothing. +-- They stay out of the delivery figures deliberately — a bot or duplicate click +-- is not delivery, and folding it in would inflate every CTR on the page — but +-- the count is worth showing, because it is most of the click volume. +-- +-- Adds a function and nothing else: no existing object is altered, so this is +-- safe to apply before the code that calls it ships. +-- +-- Apply via psql over the pooler / MCP (prod history diverged), not `db push`. + +create or replace function public.ad_slot_totals( + p_since timestamptz default null +) +returns table ( + slot_id uuid, + impressions bigint, + free_impressions bigint, + clicks bigint, + free_clicks bigint, + invalid_clicks bigint, + earned_cents bigint +) +language sql +stable +security invoker +set search_path = public +as $$ + with owned as ( + select id from public.ad_slots where owner_id = auth.uid() + ), + ev as ( + select i.slot_id, + case when i.tier = 'free' then 0 else 1 end as imp, + case when i.tier = 'free' then 1 else 0 end as free_imp, + 0 as clk, + 0 as free_clk, + 0 as invalid_clk, + 0 as earned + from public.ad_impressions i + where i.slot_id in (select id from owned) + and not i.duplicate + and (p_since is null or i.ts >= p_since) + union all + select cl.slot_id, + 0, + 0, + case when cl.valid then 1 else 0 end, + case when not cl.valid and cl.tier = 'free' then 1 else 0 end, + case when not cl.valid and cl.tier <> 'free' then 1 else 0 end, + case when cl.valid then coalesce(cl.publisher_earn_cents, 0) else 0 end + from public.ad_clicks cl + where cl.slot_id in (select id from owned) + and (p_since is null or cl.ts >= p_since) + ) + select slot_id, + sum(imp)::bigint, + sum(free_imp)::bigint, + sum(clk)::bigint, + sum(free_clk)::bigint, + sum(invalid_clk)::bigint, + sum(earned)::bigint + from ev + group by slot_id; +$$; + +grant execute on function public.ad_slot_totals(timestamptz) to authenticated, service_role; diff --git a/tests/ads-earnings-free-tier.test.ts b/tests/ads-earnings-free-tier.test.ts new file mode 100644 index 00000000..7f2d8285 --- /dev/null +++ b/tests/ads-earnings-free-tier.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { + getCampaignTotalsSince, + getSlotTotalsSince, + sinceForDays, + deliveredClicks, + deliveredImpressions, + EMPTY_SLOT_TOTALS, +} from "@/lib/ads/series"; + +// The earnings page and the slots page read `impressions` and `clicks` off +// ad_slot_stats / ad_campaign_stats, which are the tier-'paid' halves of those +// views — the free halves sit in separate columns neither page selected. Once +// every fill on the network became a self-deal, both pages read zero for months +// while the campaigns dashboard, which sums both halves, showed six figures: +// rssamplifier.com delivered 116,071 impressions and the page said 0. +// +// The same views are also lifetime, with no window, while the page and the PDF +// header both promise "last N days". + +const NOW = new Date("2026-09-01T14:26:00.000Z"); + +const clientReturning = (rows: unknown[]): any => ({ + rpc: async () => ({ data: rows, error: null }), +}); +const failingClient = (): any => ({ + rpc: async () => ({ data: null, error: { message: "boom" } }), +}); + +describe("getSlotTotalsSince", () => { + it("keeps free-tier delivery the paid column alone would hide", async () => { + const totals = await getSlotTotalsSince( + clientReturning([ + { + slot_id: "slot-rss", + impressions: 0, + free_impressions: 116071, + clicks: 0, + free_clicks: 4618, + invalid_clicks: 0, + earned_cents: 0, + }, + ]), + null, + ); + + const s = totals.get("slot-rss")!; + expect(s.impressions).toBe(0); // nothing was billable... + expect(deliveredImpressions(s)).toBe(116071); // ...but the site showed them + expect(deliveredClicks(s)).toBe(4618); + }); + + it("reports invalid clicks separately instead of as delivery", async () => { + // Folding bot clicks into the free bucket to make them visible would put a + // 16% CTR on the page. They are counted, and counted apart. + const totals = await getSlotTotalsSince( + clientReturning([ + { + slot_id: "s1", + impressions: 0, + free_impressions: 1000, + clicks: 0, + free_clicks: 10, + invalid_clicks: 57060, + earned_cents: 0, + }, + ]), + null, + ); + + const s = totals.get("s1")!; + expect(s.invalidClicks).toBe(57060); + expect(deliveredClicks(s)).toBe(10); + }); + + it("coerces the bigint-as-string counts PostgREST returns", async () => { + const totals = await getSlotTotalsSince( + clientReturning([ + { + slot_id: "s1", + impressions: "3", + free_impressions: "4", + clicks: "1", + free_clicks: "2", + invalid_clicks: "5", + earned_cents: "263", + }, + ]), + null, + ); + + expect(totals.get("s1")).toEqual({ + impressions: 3, + freeImpressions: 4, + clicks: 1, + freeClicks: 2, + invalidClicks: 5, + earnedCents: 263, + }); + }); + + it("returns an empty map rather than throwing when the RPC fails", async () => { + const totals = await getSlotTotalsSince(failingClient(), null); + expect(totals.size).toBe(0); + // Callers fall back to the zero row, so a failed RPC renders a quiet page + // rather than a 500. + expect(totals.get("missing") ?? EMPTY_SLOT_TOTALS).toEqual(EMPTY_SLOT_TOTALS); + }); +}); + +describe("getCampaignTotalsSince", () => { + it("counts both tiers, so an all-free campaign is not a dead row", async () => { + const totals = await getCampaignTotalsSince( + clientReturning([ + { + campaign_id: "c1", + impressions: 0, + free_impressions: 2269, + clicks: 0, + free_clicks: 76, + spent_cents: 0, + }, + ]), + sinceForDays(30, NOW), + ); + + const c = totals.get("c1")!; + expect(deliveredImpressions(c)).toBe(2269); + expect(deliveredClicks(c)).toBe(76); + expect(c.spentCents).toBe(0); + }); +}); + +describe("sinceForDays", () => { + it("opens the window at the start of a UTC day, matching the chart axis", () => { + // dayAxis() and ad_campaign_daily_series both work in whole UTC days, so a + // table total that started mid-day would disagree with the chart above it. + expect(sinceForDays(30, NOW)).toBe("2026-08-03T00:00:00.000Z"); + }); + + it("counts today as the first of the N days, not an extra one", () => { + expect(sinceForDays(1, NOW)).toBe("2026-09-01T00:00:00.000Z"); + expect(sinceForDays(2, NOW)).toBe("2026-08-31T00:00:00.000Z"); + }); + + it("never yields an empty or backwards window", () => { + expect(sinceForDays(0, NOW)).toBe("2026-09-01T00:00:00.000Z"); + expect(sinceForDays(-5, NOW)).toBe("2026-09-01T00:00:00.000Z"); + }); +});