Skip to content

Commit 6d96595

Browse files
ralyodioclaude
andauthored
fix(ads): count free-tier delivery in the stats box, not just paid (#199)
The four tiles on /dashboard/ads read 0 for 1W and every shorter range while the chart directly beneath them drew thousands of impressions. Nothing failed to load. The tiles counted paid inventory only, and since 2026-07-31 there has been no paid inventory: PR #177 demotes a self-owned campaign to the free tier rather than dropping it, and while every slot and every campaign belong to one account, every single fill is 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. Three fixes, one per layer: * The tiles now report delivery — paid plus free — with the split named underneath, and CTR is computed on the same totals. Spend stays strictly paid, because it is money. The per-campaign rows follow the same rule so a row can't contradict the header above it. * ad_charge_click recorded a self-deal click as `valid=false, tier='paid'`. Reporting counts `valid` as billed clicks and `not valid and tier='free'` as real-but-unbillable ones, so that combination — the one the bot/duplicate/forged path writes — made every self-deal click invisible. The branches either side of it already write 'free' for the same situation. Migration fixes the branch and reclassifies the 661 rows it mislabelled, scoped so no genuine fraud row moves. * bucketAxis stopped one bucket short of the window start. The RPC filters on `ts >= p_since` then date_bins, so it emits a partial leading bucket; getAccountSeries skips any row without a matching point, so up to a full bucket of real delivery was dropped from both the chart and the totals. Verified against prod: 1W now reports 16,220 impressions and 154 clicks where it reported 1 and 0. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c28a151 commit 6d96595

5 files changed

Lines changed: 407 additions & 22 deletions

File tree

‎app/(app)/dashboard/ads/page.tsx‎

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ import { AccountTrend } from "@/components/ads/account-trend";
77
import { RangeTabs } from "@/components/ads/range-tabs";
88
import { StatSpark } from "@/components/ads/stat-spark";
99
import {
10+
deliveredClicks,
11+
deliveredImpressions,
12+
deliverySplitNote,
1013
getAccountSeries,
1114
getCampaignDailySeries,
1215
getCampaignRangeTotals,
16+
pickDeliveredClicks,
17+
pickDeliveredImpressions,
1318
sumSeries,
1419
EMPTY_TOTALS,
1520
type AccountPoint,
@@ -123,39 +128,50 @@ export default async function AdsPage({
123128
<span className="text-sm text-[var(--color-muted)]">{range.hint}</span>
124129
</div>
125130

131+
{/* Delivery first, revenue second. The tiles count every ad actually
132+
shown — paid inventory plus free backfill — and name the split
133+
underneath, so a range in which nothing was billable reports the
134+
traffic it really carried instead of four zeros. Spend stays
135+
strictly paid: it is money, and free backfill costs none. */}
126136
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
127137
<Stat
128138
label="Impressions"
129-
value={totals.impressions.toLocaleString()}
130-
spark={<StatSpark data={series} pick={(p) => p.impressions} />}
139+
value={deliveredImpressions(totals).toLocaleString()}
140+
note={deliverySplitNote(totals.impressions, totals.freeImpressions)}
141+
spark={<StatSpark data={series} pick={pickDeliveredImpressions} />}
131142
/>
132143
<Stat
133144
label="Clicks"
134-
value={totals.clicks.toLocaleString()}
135-
spark={<StatSpark data={series} pick={(p) => p.clicks} />}
145+
value={deliveredClicks(totals).toLocaleString()}
146+
note={deliverySplitNote(totals.clicks, totals.freeClicks)}
147+
spark={<StatSpark data={series} pick={pickDeliveredClicks} />}
148+
/>
149+
<Stat
150+
label="CTR"
151+
value={ctr(deliveredClicks(totals), deliveredImpressions(totals))}
136152
/>
137-
<Stat label="CTR" value={ctr(totals.clicks, totals.impressions)} />
138153
<Stat
139154
label="Spend"
140155
value={dollars(totals.spentCents)}
156+
// Only worth saying when there was delivery to bill for; on a
157+
// silent range the $0.00 needs no explaining.
158+
note={
159+
totals.spentCents === 0 && deliveredImpressions(totals) > 0
160+
? "nothing billable"
161+
: undefined
162+
}
141163
spark={<StatSpark data={series} pick={(p) => p.spentCents} />}
142164
/>
143165
</div>
144166

145-
{/* Free backfill is delivery that costs and earns nothing, so it never
146-
belongs in the paid figures above — but hiding it entirely would
147-
make impressions look like they collapsed when a campaign runs dry. */}
167+
{/* Free backfill costs and earns nobody anything, so the reason it is
168+
free is worth one line — otherwise a dashboard full of traffic and
169+
an empty Spend tile reads as a billing fault. */}
148170
{(totals.freeImpressions > 0 || totals.freeClicks > 0) && (
149171
<p className="mt-3 text-sm text-[var(--color-muted)]">
150-
Plus{" "}
151-
<span className="font-mono font-semibold text-[var(--color-fg)]">
152-
{totals.freeImpressions.toLocaleString()}
153-
</span>{" "}
154-
free-tier impressions and{" "}
155-
<span className="font-mono font-semibold text-[var(--color-fg)]">
156-
{totals.freeClicks.toLocaleString()}
157-
</span>{" "}
158-
free clicks in this range, at no cost.
172+
Free-tier delivery is backfill: a campaign out of credits or daily
173+
budget, or one running on a slot its own account owns. It fills
174+
requests no paying advertiser wanted, bills nobody and earns nobody.
159175
</p>
160176
)}
161177

@@ -178,8 +194,10 @@ export default async function AdsPage({
178194
{campaigns.map((c) => {
179195
// Range-scoped, so a row never contradicts the header above it.
180196
const s = rangeById.get(c.id) ?? EMPTY_TOTALS;
181-
const impr = s.impressions;
182-
const clk = s.clicks;
197+
// Same measure as the header tiles, or a campaign delivering only
198+
// free backfill would read as a dead row under a live chart.
199+
const impr = deliveredImpressions(s);
200+
const clk = deliveredClicks(s);
183201
const display = campaignDisplayStatus(c, today, creditsAvailable);
184202
return (
185203
<li key={c.id} className="card p-4">
@@ -212,7 +230,10 @@ export default async function AdsPage({
212230
<MiniStat label="CTR" value={ctr(clk, impr)} />
213231
<MiniStat label="Spent" value={dollars(s.spentCents)} />
214232
{s.freeImpressions > 0 && (
215-
<MiniStat label="Free" value={s.freeImpressions.toLocaleString()} />
233+
<MiniStat
234+
label="Of which free"
235+
value={s.freeImpressions.toLocaleString()}
236+
/>
216237
)}
217238
<MiniStat
218239
label="Today"
@@ -234,16 +255,19 @@ export default async function AdsPage({
234255
function Stat({
235256
label,
236257
value,
258+
note,
237259
spark,
238260
}: {
239261
label: string;
240262
value: string;
263+
note?: string;
241264
spark?: React.ReactNode;
242265
}) {
243266
return (
244267
<div className="card p-4">
245268
<div className="text-xs uppercase tracking-wider text-[var(--color-muted)]">{label}</div>
246269
<div className="mt-1 text-2xl font-bold tabular-nums">{value}</div>
270+
{note && <div className="mt-0.5 text-xs text-[var(--color-muted)]">{note}</div>}
247271
{spark && <div className="mt-2">{spark}</div>}
248272
</div>
249273
);

‎lib/ads/ranges.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,17 @@ export function bucketAxis(range: RangeDef, now: Date = new Date()): number[] {
6969
const stepMs = range.bucketSeconds * 1000;
7070
const endMs = Math.floor(now.getTime() / stepMs) * stepMs;
7171
if (range.windowSeconds == null) return [endMs];
72-
const count = Math.ceil(range.windowSeconds / range.bucketSeconds);
72+
// Start at the bucket the window's first instant falls into, not at
73+
// `endMs - window`. Those differ whenever the window start lands mid-bucket,
74+
// which it does for every range coarser than a minute: the RPC filters rows
75+
// on `ts >= p_since` and then date_bins them, so it emits a partial leading
76+
// bucket. An axis one bucket short dropped it — getAccountSeries skips any
77+
// row with no matching point — and up to a full bucket of real delivery
78+
// vanished from the chart and the headline totals alike. The leading bucket
79+
// is partial by construction, exactly as the trailing one already is.
80+
const startMs = Math.floor((now.getTime() - range.windowSeconds * 1000) / stepMs) * stepMs;
7381
const out: number[] = [];
74-
for (let i = count - 1; i >= 0; i--) out.push(endMs - i * stepMs);
82+
for (let t = startMs; t <= endMs; t += stepMs) out.push(t);
7583
return out;
7684
}
7785

‎lib/ads/series.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,43 @@ export function sumSeries(points: AccountPoint[]): RangeTotals {
114114
);
115115
}
116116

117+
/**
118+
* Everything actually shown in the range: paid inventory plus free backfill.
119+
*
120+
* The headline tiles report this rather than the paid figure alone. Paid-only
121+
* was fine while some delivery was paid, and read as a dead dashboard the
122+
* moment none of it was — a network whose slots and campaigns belong to the
123+
* same account books every fill as free tier (serveAd demotes a self-deal),
124+
* so every tile showed 0 while the chart underneath showed thousands of
125+
* impressions. A free-tier impression is still an impression; what it isn't is
126+
* revenue, and Spend is the tile that says so.
127+
*/
128+
export function deliveredImpressions(t: RangeTotals): number {
129+
return t.impressions + t.freeImpressions;
130+
}
131+
132+
/** Clicks actually taken in the range: billed plus unbillable-but-real. */
133+
export function deliveredClicks(t: RangeTotals): number {
134+
return t.clicks + t.freeClicks;
135+
}
136+
137+
/**
138+
* Sub-line for a delivery tile: how its headline total divides into paid and
139+
* free. Silent when there is nothing to divide — a tile reading 0 needs no
140+
* footnote saying it was 0 paid and 0 free, and an all-paid tile is already
141+
* fully described by its own number.
142+
*/
143+
export function deliverySplitNote(paid: number, free: number): string | undefined {
144+
if (free === 0) return undefined;
145+
if (paid === 0) return "all free backfill";
146+
return `${paid.toLocaleString()} paid · ${free.toLocaleString()} free`;
147+
}
148+
149+
/** Sparkline accessors, so a tile's shape plots the number above it. */
150+
export const pickDeliveredImpressions = (p: AccountPoint): number =>
151+
p.impressions + p.freeImpressions;
152+
export const pickDeliveredClicks = (p: AccountPoint): number => p.clicks + p.freeClicks;
153+
117154
type CampaignTotalsRow = {
118155
campaign_id: string;
119156
impressions: number | string;
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
-- Ad network: a self-deal click is free-tier delivery, not a paid click that
2+
-- happened to fail.
3+
--
4+
-- ad_charge_click refuses to bill when the same profile owns the slot and the
5+
-- campaign — correct, there is no money to move. But that branch recorded the
6+
-- row as `valid=false, tier='paid'`, and reporting reads exactly two kinds of
7+
-- click:
8+
--
9+
-- clicks = valid -- billed
10+
-- free_clicks = not valid and tier='free' -- real, unbillable
11+
--
12+
-- `not valid and tier='paid'` is neither, and it is what the bot/duplicate/
13+
-- forged path writes. So every self-deal click landed in the bucket reserved
14+
-- for fraud and disappeared from the dashboard entirely. The two branches
15+
-- either side of it already write 'free' for the same situation — a real click
16+
-- nobody can be charged for — so this was an inconsistency, not a policy.
17+
--
18+
-- serveAd makes the matching call on the impression side: a self-owned
19+
-- campaign is demoted to the free tier rather than dropped (see the comment in
20+
-- lib/ads/serve.ts). This aligns the click side with it.
21+
--
22+
-- Backfill included, because the misclassification is recent and total: while
23+
-- every slot and every campaign belong to one account, 100% of clicks take
24+
-- this branch, and 661 of them are currently invisible. The update is scoped
25+
-- narrowly enough not to touch a genuine fraud row:
26+
--
27+
-- * valid = false and tier = 'paid' — the only rows in the wrong bucket;
28+
-- * charged_cents = 0 — never move a row that billed;
29+
-- * slot owner = campaign owner — the self-deal condition itself;
30+
-- * device is distinct from 'bot' — bots are rejected before this branch,
31+
-- so a bot row can only have come from
32+
-- the fraud path in resolveClick.
33+
--
34+
-- Duplicate-click rows cannot be caught by mistake: that check requires an
35+
-- existing valid=true click on the campaign inside 6h, and the branch being
36+
-- fixed here is precisely why no such click exists.
37+
38+
create or replace function public.ad_charge_click(
39+
p_campaign uuid,
40+
p_slot uuid,
41+
p_creative uuid,
42+
p_impression uuid,
43+
p_visitor text,
44+
p_ip_hash text,
45+
p_country text,
46+
p_device text,
47+
p_cpc_credits integer,
48+
p_platform_rate numeric
49+
)
50+
returns table(click_id uuid, charged_cents integer, publisher_earn_cents integer, valid boolean)
51+
language plpgsql
52+
security definer
53+
set search_path to 'public'
54+
as $function$
55+
declare
56+
v_owner uuid;
57+
v_status text;
58+
v_daily int;
59+
v_spend int;
60+
v_date date;
61+
v_paid int;
62+
v_bonus int;
63+
v_promo int;
64+
v_from_bonus int;
65+
v_from_promo int;
66+
v_from_cash int;
67+
v_rest int;
68+
v_slot_owner uuid;
69+
v_charged int;
70+
v_earn int;
71+
v_cut int;
72+
v_click uuid;
73+
v_rack_cents constant int := 5;
74+
v_floor_cents constant numeric := 2.0;
75+
begin
76+
select owner_id, status, daily_budget_cents, spend_today_cents, spend_date
77+
into v_owner, v_status, v_daily, v_spend, v_date
78+
from public.ad_campaigns where id = p_campaign for update;
79+
if not found then return; end if;
80+
81+
v_charged := p_cpc_credits * v_rack_cents;
82+
if v_date is distinct from current_date then v_spend := 0; end if;
83+
84+
-- Paused / archived campaign: this click should not have been servable at
85+
-- all, so it stays out of the free-tier figures as well as the paid ones.
86+
if v_status not in ('active', 'exhausted') then
87+
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)
88+
values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false,'paid')
89+
returning id into v_click;
90+
return query select v_click, 0, 0, false;
91+
return;
92+
end if;
93+
94+
select owner_id into v_slot_owner from public.ad_slots where id = p_slot;
95+
96+
-- Self-deal: one account on both sides, so nothing is billed and nothing is
97+
-- earned. Real delivery all the same — free tier, same as the two branches
98+
-- below.
99+
if v_slot_owner is not null and v_slot_owner = v_owner then
100+
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)
101+
values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false,'free')
102+
returning id into v_click;
103+
return query select v_click, 0, 0, false;
104+
return;
105+
end if;
106+
107+
if (v_spend + v_charged) > v_daily then
108+
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)
109+
values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false,'free')
110+
returning id into v_click;
111+
return query select v_click, 0, 0, false;
112+
return;
113+
end if;
114+
115+
select credits_balance,
116+
coalesce(ad_bonus_credits, 0),
117+
least(coalesce(promo_credits, 0), credits_balance)
118+
into v_paid, v_bonus, v_promo
119+
from public.profiles where id = v_owner for update;
120+
121+
if coalesce(v_paid, 0) + coalesce(v_bonus, 0) < p_cpc_credits then
122+
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)
123+
values (p_impression,p_slot,p_campaign,p_creative,p_visitor,p_ip_hash,p_country,p_device,0,0,0,false,'free')
124+
returning id into v_click;
125+
return query select v_click, 0, 0, false;
126+
return;
127+
end if;
128+
129+
v_from_bonus := least(v_bonus, p_cpc_credits);
130+
v_rest := p_cpc_credits - v_from_bonus;
131+
v_from_promo := least(v_promo, v_rest);
132+
v_from_cash := v_rest - v_from_promo;
133+
134+
update public.profiles
135+
set ad_bonus_credits = ad_bonus_credits - v_from_bonus,
136+
credits_balance = credits_balance - (v_from_promo + v_from_cash),
137+
promo_credits = greatest(0, coalesce(promo_credits, 0) - v_from_promo)
138+
where id = v_owner;
139+
140+
v_earn := floor(v_from_cash * (1 - p_platform_rate) * v_floor_cents);
141+
v_cut := v_charged - v_earn;
142+
143+
update public.ad_campaigns
144+
set spend_today_cents = v_spend + v_charged,
145+
spend_date = current_date,
146+
total_spent_cents = coalesce(total_spent_cents,0) + v_charged
147+
where id = p_campaign;
148+
149+
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)
150+
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')
151+
returning id into v_click;
152+
153+
if v_slot_owner is not null and v_earn > 0 then
154+
insert into public.ad_ledger(kind, owner_id, campaign_id, slot_id, amount_cents, ref_click_id)
155+
values ('publisher_accrual', v_slot_owner, p_campaign, p_slot, v_earn, v_click);
156+
end if;
157+
if v_cut > 0 then
158+
insert into public.ad_ledger(kind, owner_id, campaign_id, slot_id, amount_cents, ref_click_id)
159+
values ('platform_fee', null, p_campaign, p_slot, v_cut, v_click);
160+
end if;
161+
162+
return query select v_click, v_charged, v_earn, true;
163+
end $function$;
164+
165+
-- create or replace keeps the existing ACL, but state it anyway so a fresh
166+
-- database ends up where 20260731160000_ad_rpc_revoke_public.sql left this one:
167+
-- no PUBLIC execute on a security-definer money function.
168+
revoke execute on function public.ad_charge_click(uuid, uuid, uuid, uuid, text, text, text, text, integer, numeric) from public;
169+
grant execute on function public.ad_charge_click(uuid, uuid, uuid, uuid, text, text, text, text, integer, numeric) to service_role;
170+
171+
-- Reclassify the rows the old branch mislabelled. See the header for why each
172+
-- clause is here; together they select self-deal clicks and nothing else.
173+
update public.ad_clicks c
174+
set tier = 'free'
175+
from public.ad_campaigns camp, public.ad_slots s
176+
where c.campaign_id = camp.id
177+
and c.slot_id = s.id
178+
and s.owner_id = camp.owner_id
179+
and c.valid = false
180+
and c.tier = 'paid'
181+
and c.charged_cents = 0
182+
and c.device is distinct from 'bot';

0 commit comments

Comments
 (0)