Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 44 additions & 20 deletions app/(app)/dashboard/ads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -123,39 +128,50 @@ export default async function AdsPage({
<span className="text-sm text-[var(--color-muted)]">{range.hint}</span>
</div>

{/* 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. */}
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat
label="Impressions"
value={totals.impressions.toLocaleString()}
spark={<StatSpark data={series} pick={(p) => p.impressions} />}
value={deliveredImpressions(totals).toLocaleString()}
note={deliverySplitNote(totals.impressions, totals.freeImpressions)}
spark={<StatSpark data={series} pick={pickDeliveredImpressions} />}
/>
<Stat
label="Clicks"
value={totals.clicks.toLocaleString()}
spark={<StatSpark data={series} pick={(p) => p.clicks} />}
value={deliveredClicks(totals).toLocaleString()}
note={deliverySplitNote(totals.clicks, totals.freeClicks)}
spark={<StatSpark data={series} pick={pickDeliveredClicks} />}
/>
<Stat
label="CTR"
value={ctr(deliveredClicks(totals), deliveredImpressions(totals))}
/>
<Stat label="CTR" value={ctr(totals.clicks, totals.impressions)} />
<Stat
label="Spend"
value={dollars(totals.spentCents)}
// Only worth saying when there was delivery to bill for; on a
// silent range the $0.00 needs no explaining.
note={
totals.spentCents === 0 && deliveredImpressions(totals) > 0
? "nothing billable"
: undefined
}
spark={<StatSpark data={series} pick={(p) => p.spentCents} />}
/>
</div>

{/* 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) && (
<p className="mt-3 text-sm text-[var(--color-muted)]">
Plus{" "}
<span className="font-mono font-semibold text-[var(--color-fg)]">
{totals.freeImpressions.toLocaleString()}
</span>{" "}
free-tier impressions and{" "}
<span className="font-mono font-semibold text-[var(--color-fg)]">
{totals.freeClicks.toLocaleString()}
</span>{" "}
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.
</p>
)}

Expand All @@ -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 (
<li key={c.id} className="card p-4">
Expand Down Expand Up @@ -212,7 +230,10 @@ export default async function AdsPage({
<MiniStat label="CTR" value={ctr(clk, impr)} />
<MiniStat label="Spent" value={dollars(s.spentCents)} />
{s.freeImpressions > 0 && (
<MiniStat label="Free" value={s.freeImpressions.toLocaleString()} />
<MiniStat
label="Of which free"
value={s.freeImpressions.toLocaleString()}
/>
)}
<MiniStat
label="Today"
Expand All @@ -234,16 +255,19 @@ export default async function AdsPage({
function Stat({
label,
value,
note,
spark,
}: {
label: string;
value: string;
note?: string;
spark?: React.ReactNode;
}) {
return (
<div className="card p-4">
<div className="text-xs uppercase tracking-wider text-[var(--color-muted)]">{label}</div>
<div className="mt-1 text-2xl font-bold tabular-nums">{value}</div>
{note && <div className="mt-0.5 text-xs text-[var(--color-muted)]">{note}</div>}
{spark && <div className="mt-2">{spark}</div>}
</div>
);
Expand Down
12 changes: 10 additions & 2 deletions lib/ads/ranges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
37 changes: 37 additions & 0 deletions lib/ads/series.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
182 changes: 182 additions & 0 deletions supabase/migrations/20260818120000_ad_selfdeal_clicks_free_tier.sql
Original file line number Diff line number Diff line change
@@ -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';
Loading
Loading