diff --git a/app/(app)/dashboard/ads/earnings/page.tsx b/app/(app)/dashboard/ads/earnings/page.tsx index 6e4ff4d..aa576ca 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 14ee88b..2b1ff73 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 228f539..ebea250 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 2e36ba5..38fb338 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 9df1a0c..5eaeddf 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 0000000..5f3fce5 --- /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 0000000..7f2d828 --- /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"); + }); +});