Skip to content

Commit f1f87be

Browse files
ralyodioclaude
andauthored
fix(ads): demote self-owned campaigns instead of dropping them (#177)
Paid serving has been dark since ~06:04 UTC today. Every request on every format fell through to the CrawlProof house ad, and because house fills are unmetered, nothing recorded an impression — so /ads reads as zero delivery rather than as an outage. Cause: the self-deal guard filtered any campaign whose owner matches the slot owner out of the candidate list, then bailed to the house ad when the list came back empty. That is correct on a network with other advertisers. It is fatal on one without: today every active slot and all 34 active campaigns belong to the same profile, so the filter removed 100% of inventory on every request. The intent of the guard is sound — ad_charge_click refuses to bill or accrue on a self-click, so a self-owned campaign must never win PAID inventory ahead of an advertiser who would actually pay. Dropping it outright was the wrong lever. It is now demoted to the free tier, the same place a campaign that has run out of funds goes. Between a real advertiser's creative and the house ad — neither of which can earn on this request — the real creative is strictly the better fill, and it records an impression, so delivery is visible again. Tests cover both halves: a single-tenant network serves and meters again (these three fail on master, reproducing the blackout), and a self-owned campaign still never takes paid inventory from a third-party advertiser. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f36009a commit f1f87be

2 files changed

Lines changed: 209 additions & 11 deletions

File tree

‎lib/ads/serve.ts‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -151,16 +151,20 @@ export async function serveAd(
151151
Array.isArray(c) ? c[0] : c;
152152
const today = new Date().toISOString().slice(0, 10); // UTC yyyy-mm-dd
153153

154-
// Budget pacing: keep only campaigns with room for at least one more click at
155-
// their bid today. spend_today resets implicitly when spend_date is earlier.
156-
// Never serve someone their own ad on their own slot. ad_charge_click refuses
157-
// to bill or accrue on a self-click anyway; filtering here means we don't burn
158-
// an impression and a redirect on a click that can't earn.
159-
const candidates = (creatives as unknown as Row[]).filter((row) => {
160-
const c = oneCampaign(row.ad_campaigns);
161-
if (!c) return false;
162-
return !(slot.owner_id && c.owner_id === slot.owner_id);
163-
});
154+
// A self-owned campaign (same profile owns the slot and the campaign) can
155+
// never earn: ad_charge_click refuses to bill or accrue on a self-click. It
156+
// used to be dropped here outright, which is correct on a network with other
157+
// advertisers and catastrophic on one without — while every slot and every
158+
// campaign belong to the same account, that filter removed 100% of inventory
159+
// and every request fell through to the house ad. Serving stopped entirely
160+
// and nothing recorded an impression, because house fills aren't metered.
161+
//
162+
// So self-owned campaigns are demoted rather than discarded, below. Between a
163+
// real advertiser's creative and the house ad — neither of which can earn on
164+
// this request — the real creative is strictly the better fill.
165+
const candidates = (creatives as unknown as Row[]).filter(
166+
(row) => !!oneCampaign(row.ad_campaigns),
167+
);
164168
if (candidates.length === 0) return houseFill(format);
165169

166170
// A campaign is PAID-eligible only if its owner can actually cover a click at
@@ -187,9 +191,14 @@ export async function serveAd(
187191
const spentToday = c.spend_date === today ? c.spend_today_cents : 0;
188192
const hasBudget = spentToday + bid * CREDIT_CENTS <= c.daily_budget_cents;
189193
const hasFunds = (creditsByOwner.get(c.owner_id) ?? 0) >= bid;
194+
// Same owner on both sides of the transaction: the click can't be billed,
195+
// so it must never win paid inventory ahead of an advertiser who would
196+
// actually pay. Free tier is exactly the right home for it — same place a
197+
// campaign that has run out of funds goes.
198+
const isSelfDeal = !!(slot.owner_id && c.owner_id === slot.owner_id);
190199
// Legacy 'exhausted' rows never compete for paid inventory on that status
191200
// alone — funds decide, and a top-up puts them straight back in the auction.
192-
(hasBudget && hasFunds ? paid : free).push(row);
201+
(hasBudget && hasFunds && !isSelfDeal ? paid : free).push(row);
193202
}
194203

195204
// Paid inventory first, always. Free-tier campaigns only ever fill requests no
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
// Regression: a self-owned campaign (same profile owns the slot and the
4+
// campaign) used to be filtered out of the candidate list entirely. That is
5+
// right on a network with other advertisers, and fatal on one without — while
6+
// every slot and campaign belonged to a single account it removed 100% of
7+
// inventory, every request fell through to the house ad, and because house
8+
// fills are unmetered, impressions stopped being recorded at all.
9+
//
10+
// The rule that must survive: a self-deal can never win PAID inventory ahead of
11+
// an advertiser who would actually pay. The rule that was wrong: dropping it.
12+
13+
// Declared inside vi.hoisted: the mock factory below is hoisted above ordinary
14+
// const declarations, so it cannot close over them.
15+
const H = vi.hoisted(() => {
16+
const OWNER = "11111111-1111-1111-1111-111111111111";
17+
const OTHER = "22222222-2222-2222-2222-222222222222";
18+
return {
19+
OWNER,
20+
OTHER,
21+
state: {
22+
slotOwner: OWNER as string | null,
23+
campaignOwners: [OWNER] as string[],
24+
credits: 9999,
25+
inserted: [] as Record<string, unknown>[],
26+
},
27+
};
28+
});
29+
const { OWNER, OTHER, state } = H;
30+
31+
function chain(result: unknown): unknown {
32+
const c: unknown = new Proxy(
33+
{},
34+
{
35+
get(_t, prop) {
36+
if (prop === "then") {
37+
return (res: (v: unknown) => unknown, rej: (e: unknown) => unknown) =>
38+
Promise.resolve(result).then(res, rej);
39+
}
40+
return () => c;
41+
},
42+
},
43+
);
44+
return c;
45+
}
46+
47+
function creativeFor(ownerId: string, i: number) {
48+
return {
49+
id: `cre-${i}`,
50+
campaign_id: `camp-${i}`,
51+
format: "terminal_ascii",
52+
headline: `Advertiser ${i}`,
53+
body: "Real advertiser copy.",
54+
cta_text: "Go",
55+
image_url: null,
56+
logo_url: null,
57+
bg_color: "#0b0d10",
58+
fg_color: "#e7e9ee",
59+
accent_color: "#6ee7b7",
60+
font_family: "system-ui",
61+
ad_campaigns: {
62+
id: `camp-${i}`,
63+
owner_id: ownerId,
64+
status: "active",
65+
ref_slug: `adv-${i}`,
66+
destination_url: "https://advertiser.example/",
67+
daily_budget_cents: 500,
68+
spend_today_cents: 0,
69+
spend_date: null,
70+
bid_credits: 4,
71+
},
72+
};
73+
}
74+
75+
vi.mock("@/lib/supabase/service", () => ({
76+
serviceClient: () => ({
77+
from(table: string) {
78+
if (table === "ad_slots") {
79+
return chain({
80+
data: {
81+
id: "slot-1",
82+
status: "active",
83+
formats: ["terminal_ascii"],
84+
owner_id: state.slotOwner,
85+
},
86+
error: null,
87+
});
88+
}
89+
if (table === "ad_creatives") {
90+
return chain({
91+
data: state.campaignOwners.map((o, i) => creativeFor(o, i)),
92+
error: null,
93+
});
94+
}
95+
if (table === "profiles") {
96+
return chain({
97+
data: [...new Set(state.campaignOwners)].map((id) => ({
98+
id,
99+
credits_balance: state.credits,
100+
ad_bonus_credits: 0,
101+
})),
102+
error: null,
103+
});
104+
}
105+
if (table === "ad_impressions") {
106+
return {
107+
insert(payload: Record<string, unknown>) {
108+
state.inserted.push(payload);
109+
return chain({ data: { id: "imp-1", ...payload }, error: null });
110+
},
111+
};
112+
}
113+
return chain({ data: null, error: null });
114+
},
115+
}),
116+
}));
117+
118+
async function serve() {
119+
const { serveAd } = await import("@/lib/ads/serve");
120+
return serveAd("slot-1", "terminal_ascii", { device: "terminal" });
121+
}
122+
123+
/** Fill repeatedly, since ~10% of paid-eligible fills are house by design. */
124+
async function fills(n: number) {
125+
const out = [];
126+
for (let i = 0; i < n; i++) out.push(await serve());
127+
return out;
128+
}
129+
130+
describe("self-owned campaigns on a single-tenant network", () => {
131+
beforeEach(() => {
132+
vi.resetModules();
133+
state.slotOwner = OWNER;
134+
state.campaignOwners = [OWNER, OWNER, OWNER];
135+
state.credits = 9999;
136+
state.inserted = [];
137+
});
138+
139+
it("still serves real creatives when the only campaigns are self-owned", async () => {
140+
const served = await fills(40);
141+
const real = served.filter((f) => f && f.campaignId !== "house");
142+
// The blackout: every one of these came back as the house ad.
143+
expect(real.length).toBeGreaterThan(0);
144+
expect(real[0]!.creative.headline).toMatch(/^Advertiser /);
145+
});
146+
147+
it("records impressions again, so the dashboard is not blank", async () => {
148+
await fills(40);
149+
expect(state.inserted.length).toBeGreaterThan(0);
150+
});
151+
152+
it("meters a self-deal as free, never as paid", async () => {
153+
await fills(40);
154+
const tiers = new Set(state.inserted.map((r) => r.tier));
155+
expect(tiers).toEqual(new Set(["free"]));
156+
});
157+
});
158+
159+
describe("self-deal still loses to a real advertiser", () => {
160+
beforeEach(() => {
161+
vi.resetModules();
162+
state.slotOwner = OWNER;
163+
state.credits = 9999;
164+
state.inserted = [];
165+
});
166+
167+
it("never gives paid inventory to the slot owner's own campaign", async () => {
168+
// One self-owned campaign, one genuine third-party advertiser.
169+
state.campaignOwners = [OWNER, OTHER];
170+
await fills(60);
171+
const paid = state.inserted.filter((r) => r.tier === "paid");
172+
expect(paid.length).toBeGreaterThan(0);
173+
// camp-0 is the self-owned one; it must never be billed against this slot.
174+
for (const row of paid) expect(row.campaign_id).toBe("camp-1");
175+
});
176+
177+
it("puts a third-party advertiser on the paid tier", async () => {
178+
state.campaignOwners = [OTHER];
179+
await fills(40);
180+
expect(state.inserted.some((r) => r.tier === "paid")).toBe(true);
181+
});
182+
183+
it("keeps a slot with no owner working", async () => {
184+
state.slotOwner = null;
185+
state.campaignOwners = [OWNER];
186+
await fills(40);
187+
expect(state.inserted.some((r) => r.tier === "paid")).toBe(true);
188+
});
189+
});

0 commit comments

Comments
 (0)