diff --git a/app/(app)/dashboard/ads/page.tsx b/app/(app)/dashboard/ads/page.tsx
index 3be82d3..61ccd70 100644
--- a/app/(app)/dashboard/ads/page.tsx
+++ b/app/(app)/dashboard/ads/page.tsx
@@ -7,9 +7,14 @@ import { AccountTrend } from "@/components/ads/account-trend";
import { RangeTabs } from "@/components/ads/range-tabs";
import { StatSpark } from "@/components/ads/stat-spark";
import {
+ deliveredClicks,
+ deliveredImpressions,
+ deliverySplitNote,
getAccountSeries,
getCampaignDailySeries,
getCampaignRangeTotals,
+ pickDeliveredClicks,
+ pickDeliveredImpressions,
sumSeries,
EMPTY_TOTALS,
type AccountPoint,
@@ -123,39 +128,50 @@ export default async function AdsPage({
{range.hint}
+ {/* Delivery first, revenue second. The tiles count every ad actually
+ shown — paid inventory plus free backfill — and name the split
+ underneath, so a range in which nothing was billable reports the
+ traffic it really carried instead of four zeros. Spend stays
+ strictly paid: it is money, and free backfill costs none. */}
p.impressions} />}
+ value={deliveredImpressions(totals).toLocaleString()}
+ note={deliverySplitNote(totals.impressions, totals.freeImpressions)}
+ spark={}
/>
p.clicks} />}
+ value={deliveredClicks(totals).toLocaleString()}
+ note={deliverySplitNote(totals.clicks, totals.freeClicks)}
+ spark={}
+ />
+
-
0
+ ? "nothing billable"
+ : undefined
+ }
spark={ p.spentCents} />}
/>
- {/* Free backfill is delivery that costs and earns nothing, so it never
- belongs in the paid figures above — but hiding it entirely would
- make impressions look like they collapsed when a campaign runs dry. */}
+ {/* Free backfill costs and earns nobody anything, so the reason it is
+ free is worth one line — otherwise a dashboard full of traffic and
+ an empty Spend tile reads as a billing fault. */}
{(totals.freeImpressions > 0 || totals.freeClicks > 0) && (
- Plus{" "}
-
- {totals.freeImpressions.toLocaleString()}
- {" "}
- free-tier impressions and{" "}
-
- {totals.freeClicks.toLocaleString()}
- {" "}
- free clicks in this range, at no cost.
+ Free-tier delivery is backfill: a campaign out of credits or daily
+ budget, or one running on a slot its own account owns. It fills
+ requests no paying advertiser wanted, bills nobody and earns nobody.
)}
@@ -178,8 +194,10 @@ export default async function AdsPage({
{campaigns.map((c) => {
// Range-scoped, so a row never contradicts the header above it.
const s = rangeById.get(c.id) ?? EMPTY_TOTALS;
- const impr = s.impressions;
- const clk = s.clicks;
+ // Same measure as the header tiles, or a campaign delivering only
+ // free backfill would read as a dead row under a live chart.
+ const impr = deliveredImpressions(s);
+ const clk = deliveredClicks(s);
const display = campaignDisplayStatus(c, today, creditsAvailable);
return (
@@ -212,7 +230,10 @@ export default async function AdsPage({
{s.freeImpressions > 0 && (
-
+
)}
{label}
{value}
+ {note && {note}
}
{spark && {spark}
}
);
diff --git a/lib/ads/ranges.ts b/lib/ads/ranges.ts
index f575cc2..29ad08a 100644
--- a/lib/ads/ranges.ts
+++ b/lib/ads/ranges.ts
@@ -69,9 +69,17 @@ export function bucketAxis(range: RangeDef, now: Date = new Date()): number[] {
const stepMs = range.bucketSeconds * 1000;
const endMs = Math.floor(now.getTime() / stepMs) * stepMs;
if (range.windowSeconds == null) return [endMs];
- const count = Math.ceil(range.windowSeconds / range.bucketSeconds);
+ // Start at the bucket the window's first instant falls into, not at
+ // `endMs - window`. Those differ whenever the window start lands mid-bucket,
+ // which it does for every range coarser than a minute: the RPC filters rows
+ // on `ts >= p_since` and then date_bins them, so it emits a partial leading
+ // bucket. An axis one bucket short dropped it — getAccountSeries skips any
+ // row with no matching point — and up to a full bucket of real delivery
+ // vanished from the chart and the headline totals alike. The leading bucket
+ // is partial by construction, exactly as the trailing one already is.
+ const startMs = Math.floor((now.getTime() - range.windowSeconds * 1000) / stepMs) * stepMs;
const out: number[] = [];
- for (let i = count - 1; i >= 0; i--) out.push(endMs - i * stepMs);
+ for (let t = startMs; t <= endMs; t += stepMs) out.push(t);
return out;
}
diff --git a/lib/ads/series.ts b/lib/ads/series.ts
index 4b5f136..2e36ba5 100644
--- a/lib/ads/series.ts
+++ b/lib/ads/series.ts
@@ -114,6 +114,43 @@ export function sumSeries(points: AccountPoint[]): RangeTotals {
);
}
+/**
+ * Everything actually shown in the range: paid inventory plus free backfill.
+ *
+ * The headline tiles report this rather than the paid figure alone. Paid-only
+ * was fine while some delivery was paid, and read as a dead dashboard the
+ * moment none of it was — a network whose slots and campaigns belong to the
+ * same account books every fill as free tier (serveAd demotes a self-deal),
+ * so every tile showed 0 while the chart underneath showed thousands of
+ * 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 {
+ return t.impressions + t.freeImpressions;
+}
+
+/** Clicks actually taken in the range: billed plus unbillable-but-real. */
+export function deliveredClicks(t: RangeTotals): number {
+ return t.clicks + t.freeClicks;
+}
+
+/**
+ * Sub-line for a delivery tile: how its headline total divides into paid and
+ * free. Silent when there is nothing to divide — a tile reading 0 needs no
+ * footnote saying it was 0 paid and 0 free, and an all-paid tile is already
+ * fully described by its own number.
+ */
+export function deliverySplitNote(paid: number, free: number): string | undefined {
+ if (free === 0) return undefined;
+ if (paid === 0) return "all free backfill";
+ return `${paid.toLocaleString()} paid · ${free.toLocaleString()} free`;
+}
+
+/** Sparkline accessors, so a tile's shape plots the number above it. */
+export const pickDeliveredImpressions = (p: AccountPoint): number =>
+ p.impressions + p.freeImpressions;
+export const pickDeliveredClicks = (p: AccountPoint): number => p.clicks + p.freeClicks;
+
type CampaignTotalsRow = {
campaign_id: string;
impressions: number | string;
diff --git a/supabase/migrations/20260818120000_ad_selfdeal_clicks_free_tier.sql b/supabase/migrations/20260818120000_ad_selfdeal_clicks_free_tier.sql
new file mode 100644
index 0000000..fdb698c
--- /dev/null
+++ b/supabase/migrations/20260818120000_ad_selfdeal_clicks_free_tier.sql
@@ -0,0 +1,182 @@
+-- Ad network: a self-deal click is free-tier delivery, not a paid click that
+-- happened to fail.
+--
+-- ad_charge_click refuses to bill when the same profile owns the slot and the
+-- campaign — correct, there is no money to move. But that branch recorded the
+-- row as `valid=false, tier='paid'`, and reporting reads exactly two kinds of
+-- click:
+--
+-- clicks = valid -- billed
+-- free_clicks = not valid and tier='free' -- real, unbillable
+--
+-- `not valid and tier='paid'` is neither, and it is what the bot/duplicate/
+-- forged path writes. So every self-deal click landed in the bucket reserved
+-- for fraud and disappeared from the dashboard entirely. The two branches
+-- either side of it already write 'free' for the same situation — a real click
+-- nobody can be charged for — so this was an inconsistency, not a policy.
+--
+-- serveAd makes the matching call on the impression side: a self-owned
+-- campaign is demoted to the free tier rather than dropped (see the comment in
+-- lib/ads/serve.ts). This aligns the click side with it.
+--
+-- Backfill included, because the misclassification is recent and total: while
+-- every slot and every campaign belong to one account, 100% of clicks take
+-- this branch, and 661 of them are currently invisible. The update is scoped
+-- narrowly enough not to touch a genuine fraud row:
+--
+-- * valid = false and tier = 'paid' — the only rows in the wrong bucket;
+-- * charged_cents = 0 — never move a row that billed;
+-- * slot owner = campaign owner — the self-deal condition itself;
+-- * device is distinct from 'bot' — bots are rejected before this branch,
+-- so a bot row can only have come from
+-- the fraud path in resolveClick.
+--
+-- Duplicate-click rows cannot be caught by mistake: that check requires an
+-- existing valid=true click on the campaign inside 6h, and the branch being
+-- fixed here is precisely why no such click exists.
+
+create or replace function public.ad_charge_click(
+ p_campaign uuid,
+ p_slot uuid,
+ p_creative uuid,
+ p_impression uuid,
+ p_visitor text,
+ p_ip_hash text,
+ p_country text,
+ p_device text,
+ p_cpc_credits integer,
+ p_platform_rate numeric
+)
+returns table(click_id uuid, charged_cents integer, publisher_earn_cents integer, valid boolean)
+language plpgsql
+security definer
+set search_path to 'public'
+as $function$
+declare
+ v_owner uuid;
+ v_status text;
+ v_daily int;
+ v_spend int;
+ v_date date;
+ v_paid int;
+ v_bonus int;
+ v_promo int;
+ v_from_bonus int;
+ v_from_promo int;
+ v_from_cash int;
+ v_rest int;
+ v_slot_owner uuid;
+ v_charged int;
+ v_earn int;
+ v_cut int;
+ v_click uuid;
+ v_rack_cents constant int := 5;
+ v_floor_cents constant numeric := 2.0;
+begin
+ select owner_id, status, daily_budget_cents, spend_today_cents, spend_date
+ into v_owner, v_status, v_daily, v_spend, v_date
+ from public.ad_campaigns where id = p_campaign for update;
+ if not found then return; end if;
+
+ v_charged := p_cpc_credits * v_rack_cents;
+ if v_date is distinct from current_date then v_spend := 0; end if;
+
+ -- Paused / archived campaign: this click should not have been servable at
+ -- all, so it stays out of the free-tier figures as well as the paid ones.
+ if v_status not in ('active', 'exhausted') then
+ insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid,tier)
+ values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false,'paid')
+ returning id into v_click;
+ return query select v_click, 0, 0, false;
+ return;
+ end if;
+
+ select owner_id into v_slot_owner from public.ad_slots where id = p_slot;
+
+ -- Self-deal: one account on both sides, so nothing is billed and nothing is
+ -- earned. Real delivery all the same — free tier, same as the two branches
+ -- below.
+ if v_slot_owner is not null and v_slot_owner = v_owner then
+ insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid,tier)
+ values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false,'free')
+ returning id into v_click;
+ return query select v_click, 0, 0, false;
+ return;
+ end if;
+
+ if (v_spend + v_charged) > v_daily then
+ insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid,tier)
+ values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false,'free')
+ returning id into v_click;
+ return query select v_click, 0, 0, false;
+ return;
+ end if;
+
+ select credits_balance,
+ coalesce(ad_bonus_credits, 0),
+ least(coalesce(promo_credits, 0), credits_balance)
+ into v_paid, v_bonus, v_promo
+ from public.profiles where id = v_owner for update;
+
+ if coalesce(v_paid, 0) + coalesce(v_bonus, 0) < p_cpc_credits then
+ insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid,tier)
+ values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false,'free')
+ returning id into v_click;
+ return query select v_click, 0, 0, false;
+ return;
+ end if;
+
+ v_from_bonus := least(v_bonus, p_cpc_credits);
+ v_rest := p_cpc_credits - v_from_bonus;
+ v_from_promo := least(v_promo, v_rest);
+ v_from_cash := v_rest - v_from_promo;
+
+ update public.profiles
+ set ad_bonus_credits = ad_bonus_credits - v_from_bonus,
+ credits_balance = credits_balance - (v_from_promo + v_from_cash),
+ promo_credits = greatest(0, coalesce(promo_credits, 0) - v_from_promo)
+ where id = v_owner;
+
+ v_earn := floor(v_from_cash * (1 - p_platform_rate) * v_floor_cents);
+ v_cut := v_charged - v_earn;
+
+ update public.ad_campaigns
+ set spend_today_cents = v_spend + v_charged,
+ spend_date = current_date,
+ total_spent_cents = coalesce(total_spent_cents,0) + v_charged
+ where id = p_campaign;
+
+ insert into public.ad_clicks(impression_id,slot_id,campaign_id,creative_id,visitor_id,ip_hash,geo_country,device,charged_cents,publisher_earn_cents,platform_cut_cents,valid,tier)
+ values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,v_charged,v_earn,v_cut,true,'paid')
+ returning id into v_click;
+
+ if v_slot_owner is not null and v_earn > 0 then
+ insert into public.ad_ledger(kind, owner_id, campaign_id, slot_id, amount_cents, ref_click_id)
+ values ('publisher_accrual', v_slot_owner, p_campaign, p_slot, v_earn, v_click);
+ end if;
+ if v_cut > 0 then
+ insert into public.ad_ledger(kind, owner_id, campaign_id, slot_id, amount_cents, ref_click_id)
+ values ('platform_fee', null, p_campaign, p_slot, v_cut, v_click);
+ end if;
+
+ return query select v_click, v_charged, v_earn, true;
+end $function$;
+
+-- create or replace keeps the existing ACL, but state it anyway so a fresh
+-- database ends up where 20260731160000_ad_rpc_revoke_public.sql left this one:
+-- no PUBLIC execute on a security-definer money function.
+revoke execute on function public.ad_charge_click(uuid, uuid, uuid, uuid, text, text, text, text, integer, numeric) from public;
+grant execute on function public.ad_charge_click(uuid, uuid, uuid, uuid, text, text, text, text, integer, numeric) to service_role;
+
+-- Reclassify the rows the old branch mislabelled. See the header for why each
+-- clause is here; together they select self-deal clicks and nothing else.
+update public.ad_clicks c
+ set tier = 'free'
+ from public.ad_campaigns camp, public.ad_slots s
+ where c.campaign_id = camp.id
+ and c.slot_id = s.id
+ and s.owner_id = camp.owner_id
+ and c.valid = false
+ and c.tier = 'paid'
+ and c.charged_cents = 0
+ and c.device is distinct from 'bot';
diff --git a/tests/ads-stats-box.test.ts b/tests/ads-stats-box.test.ts
new file mode 100644
index 0000000..b658b6c
--- /dev/null
+++ b/tests/ads-stats-box.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it } from "vitest";
+import {
+ deliveredClicks,
+ deliveredImpressions,
+ deliverySplitNote,
+ getAccountSeries,
+ pickDeliveredClicks,
+ pickDeliveredImpressions,
+ sumSeries,
+ EMPTY_TOTALS,
+} from "@/lib/ads/series";
+import { RANGES, bucketAxis, bucketOf, rangeSince, type RangeId } from "@/lib/ads/ranges";
+
+// The stats box above the delivery chart went blank for 1W and every shorter
+// range while the chart under it drew thousands of impressions. Nothing was
+// failing to load: the tiles counted paid delivery only, and once every slot
+// and every campaign belonged to one account, serveAd demoted every fill to the
+// free tier as a self-deal. 1M and wider still reached back to genuinely paid
+// days, which is exactly why the break looked like a short-range bug.
+
+const NOW = new Date("2026-08-18T09:50:00.000Z");
+const byId = (id: RangeId) => RANGES.find((r) => r.id === id)!;
+
+/** Minimal stand-in for the Supabase client: getAccountSeries only calls .rpc. */
+const clientReturning = (rows: unknown[]): any => ({
+ rpc: async () => ({ data: rows, error: null }),
+});
+const failingClient = (): any => ({
+ rpc: async () => ({ data: null, error: { message: "boom" } }),
+});
+
+const row = (bucket: string, over: Record = {}) => ({
+ bucket,
+ impressions: 0,
+ free_impressions: 0,
+ clicks: 0,
+ free_clicks: 0,
+ spent_cents: 0,
+ ...over,
+});
+
+describe("delivered totals", () => {
+ it("counts free backfill as delivery, because it was delivered", () => {
+ const totals = { ...EMPTY_TOTALS, impressions: 0, freeImpressions: 16207 };
+ expect(deliveredImpressions(totals)).toBe(16207);
+ });
+
+ it("counts an unbillable click as a click", () => {
+ const totals = { ...EMPTY_TOTALS, clicks: 0, freeClicks: 649 };
+ expect(deliveredClicks(totals)).toBe(649);
+ });
+
+ it("adds the two tiers rather than preferring one", () => {
+ const totals = { ...EMPTY_TOTALS, impressions: 12, freeImpressions: 30, clicks: 2, freeClicks: 5 };
+ expect(deliveredImpressions(totals)).toBe(42);
+ expect(deliveredClicks(totals)).toBe(7);
+ });
+
+ it("gives the sparklines the same measure as the number above them", () => {
+ const p = { t: 0, impressions: 3, freeImpressions: 4, clicks: 1, freeClicks: 2, spentCents: 9 };
+ expect(pickDeliveredImpressions(p)).toBe(7);
+ expect(pickDeliveredClicks(p)).toBe(3);
+ });
+});
+
+describe("delivery split note", () => {
+ it("says nothing when there is no free tier to explain", () => {
+ expect(deliverySplitNote(500, 0)).toBeUndefined();
+ expect(deliverySplitNote(0, 0)).toBeUndefined();
+ });
+
+ it("names the all-free case outright, so a $0 spend reads as intended", () => {
+ expect(deliverySplitNote(0, 16207)).toBe("all free backfill");
+ });
+
+ it("gives both halves when delivery is mixed", () => {
+ expect(deliverySplitNote(1200, 300)).toBe("1,200 paid · 300 free");
+ });
+});
+
+describe("getAccountSeries", () => {
+ it("keeps free-tier delivery that the paid figure alone would hide", async () => {
+ const range = byId("1w");
+ const axis = bucketAxis(range, NOW);
+ const rows = [row(new Date(axis.at(-1)!).toISOString(), { free_impressions: 240, free_clicks: 3 })];
+
+ const points = await getAccountSeries(clientReturning(rows), range, NOW);
+ const totals = sumSeries(points);
+
+ expect(totals.impressions).toBe(0); // nothing was billable...
+ expect(deliveredImpressions(totals)).toBe(240); // ...but 240 ads were shown
+ expect(deliveredClicks(totals)).toBe(3);
+ });
+
+ it("keeps the partial bucket at the start of the window", async () => {
+ // The window opens mid-bucket for every range coarser than a minute, so the
+ // RPC emits a leading bucket that starts before `since`. The axis used to
+ // stop one bucket short and getAccountSeries dropped the row on the floor —
+ // silently, since an unmatched bucket is skipped rather than appended.
+ for (const range of RANGES) {
+ if (range.windowSeconds == null) continue;
+ const since = rangeSince(range, NOW)!;
+ const rows = [row(new Date(bucketOf(since, range)).toISOString(), { impressions: 7 })];
+
+ const points = await getAccountSeries(clientReturning(rows), range, NOW);
+ expect(sumSeries(points).impressions, `${range.id} dropped its first bucket`).toBe(7);
+ }
+ });
+
+ it("still covers the whole window without duplicating a bucket", () => {
+ for (const range of RANGES) {
+ if (range.windowSeconds == null) continue;
+ const axis = bucketAxis(range, NOW);
+ expect(new Set(axis).size).toBe(axis.length);
+ expect(axis[0]).toBeLessThanOrEqual(NOW.getTime() - range.windowSeconds * 1000);
+ // One partial bucket of slack at each end, no more.
+ expect(axis[0]).toBeGreaterThan(
+ NOW.getTime() - (range.windowSeconds + range.bucketSeconds) * 1000,
+ );
+ }
+ });
+
+ it("zero-fills the whole axis so a quiet range still draws a line", async () => {
+ const range = byId("1d");
+ const points = await getAccountSeries(clientReturning([]), range, NOW);
+ expect(points).toHaveLength(bucketAxis(range, NOW).length);
+ expect(sumSeries(points)).toEqual(EMPTY_TOTALS);
+ });
+
+ it("renders an empty range rather than throwing when the RPC fails", async () => {
+ const points = await getAccountSeries(failingClient(), byId("1h"), NOW);
+ expect(sumSeries(points)).toEqual(EMPTY_TOTALS);
+ });
+});