fix(ads): count free-tier delivery in the stats box, not just paid - #199
Merged
Merged
Conversation
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>
ThreatCrush Security Scan35 finding(s) HIGH/CRITICAL: 3 | MEDIUM: 23 | LOW: 9
Snippets are redacted; ThreatCrush never prints matched credential material. |
ralyodio
added a commit
that referenced
this pull request
Sep 1, 2026
The earnings page and the slots page read `impressions` and `clicks` off ad_slot_stats / ad_campaign_stats. Those are the tier-'paid' halves of the views; the free halves sit in `free_impressions` / `free_clicks`, which neither page ever selected. Once every slot and every campaign belonged to one account, ad_charge_click took its self-deal branch on every fill and paid delivery stopped: 17,303 paid impressions in July, 3 in August, 0 in September. So both pages have read zero ever since, while the campaigns dashboard — which sums both halves after #199 — showed six figures over the same window. rssamplifier.com delivered 116,071 impressions and its row said 0. #199 fixed this measure on the campaigns dashboard and did not reach here. The same views are also lifetime, with no window at all, while the page and the PDF header both promise "last N days" — so the figures were wrong twice over: the wrong tier, for the wrong period. loadEarnings now reads ad_campaign_totals and a new ad_slot_totals, both windowed and both returning each tier, and the window is the same whole-UTC-day span the chart above the tables already drew. Money stays lifetime on purpose, and the page now says so. "Available to withdraw" is lifetime earnings minus lifetime payouts; scoping either side to 30 days would under-report a balance the account is actually owed. The tiles are grouped under "Balance · all time" and the tables under "Delivery · last 30 days" rather than one heading claiming a period for both. Invalid clicks were the third gap. A click we refuse to bill is recorded with valid = false, and resolveClick's insert left `tier` at its 'paid' default, so the row matched neither the billed bucket (valid) nor the free bucket (not valid and tier = 'free'). 57,060 clicks had collected there, visible to nothing. They stay out of the delivery figures deliberately — a bot click is not delivery, and folding it in would put a 16% CTR on the page — but ad_slot_totals returns the count and the page reports it in a line of its own. The insert now writes `tier` explicitly, so the bucket is a decision rather than a default. Verified against prod: ad_slot_totals over the last 30 days returns 174,959 impressions / 5,550 clicks / 57,063 invalid, matching a raw count over ad_impressions and ad_clicks exactly, in ~360ms against an 8s statement timeout. The migration adds a function and alters nothing, so it is already applied. Claude-Session: https://claude.ai/code/session_0147H2VoYJS2WUz4JQmaLQKV Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio
added a commit
that referenced
this pull request
Sep 1, 2026
) /dashboard/ads read 0 for everything, intermittently, while the account was delivering 176,264 impressions over the window. The measure was right this time -- #199 and #225 both hold -- and the data was there. The RPCs were being cancelled. ad_account_series, ad_campaign_totals and the two daily-series functions are security invoker, so the RLS policy on ad_impressions ("slot is mine OR campaign is mine") joins the plan. With it the planner abandons the hash join for a nested loop: one index scan per owned campaign, 139 loops, ~176k random heap fetches, 401,791 buffers (~3GB) touched per page load. ad_impressions passed 364k rows / 154MB and traffic ran 10x baseline on 2026-09-01, which tipped it over the 8s statement_timeout on `authenticated` -- 34 cancellations in two hours, surfacing as HTTP 500 on three RPCs. Each function already did its own authorisation and never relied on RLS for it: every read is gated by `<x>_id in (select id from owned)` where owned is `owner_id = auth.uid()`. Running them as definer drops the RLS subplans and the planner picks the hash join again: 11,818 buffers / 208ms against 401,791 / 932ms, byte-identical output. Verified with a stranger's JWT that all five still return 0 rows. Note the guard is `in` and not `not in`, so an anon caller gets an empty `owned` rather than everything. The second half is why this took a log dive to find. Every loader swallowed the error into a zero-filled result, so a cancelled query and a genuinely quiet range produced identical output and the page reported four confident zeros over a live network. The zero-fill stays -- one bad panel should not take the page down -- but the loaders now return Loaded<T> carrying `failed`, log the error instead of discarding it, and the four ad surfaces render "couldn't load" in place of the zeros. The PDF report says so too: that document goes to accountants, where a silent zero is read as fact. Migration is already applied to prod. Claude-Session: https://claude.ai/code/session_01318XDMF7H8AtH7h4ZjweTS Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
/dashboard/ads?range=1w— and 1D, 4H, 1H — showed 0 impressions, 0 clicks, — CTR, $0.00 spend while the "Delivery over time" chart right underneath drew thousands of impressions. 1M / 3M / 1Y / ALL looked fine, which is what made it read as a short-range loading bug.Nothing was failing to load. The tiles counted paid inventory only, and there has been no paid inventory since 2026-07-31:
serveAddemotes a self-deal (same account owns the slot and the campaign) to the free tier instead of dropping it — #177, correct and deliberate. But while every slot and every campaign on the network belong to one account, every fill is a self-deal, so 100% of delivery books astier='free'. 1M and wider still reach back past 2026-07-31 to genuinely paid days. That's the whole "1 week and smaller" pattern.What this changes
1. The stats box reports delivery, not just revenue. Impressions and Clicks now count paid + free with the split named underneath (
1,200 paid · 300 free, orall free backfill), and CTR uses the same totals. Spend stays strictly paid — it's money — and saysnothing billablewhen there was delivery but no spend. Per-campaign rows follow the same rule so a row can't contradict the header above it.2. Self-deal clicks were invisible, not merely unpaid.
ad_charge_clickwrote them asvalid=false, tier='paid'. Reporting reads exactly two kinds of click:not valid and tier='paid'is neither — it's what the bot/duplicate/forged path writes. The branches on either side of the self-deal check already write'free'for the same situation, so this was an inconsistency rather than a policy. The migration fixes the branch and reclassifies the 661 rows it mislabelled, scoped bycharged_cents = 0+ slot-owner = campaign-owner +device <> 'bot'so no genuine fraud row moves. (Duplicate rows can't be caught by accident: that check requires an existingvalid=trueclick within 6h, and this bug is why none exists.)3.
bucketAxisdropped the first bucket of every range. The RPC filters onts >= p_sinceand thendate_bins, so it emits a partial leading bucket that starts before the window. The axis stopped one bucket short andgetAccountSeriessilently skips any row without a matching point — up to a full bucket of real delivery vanished from both the chart and the totals.Migration
supabase/migrations/20260818120000_ad_selfdeal_clicks_free_tier.sql— already applied to prod (migrations here are applied by hand; applying before merge is the repo's rule). 661 rows reclassified.Verification
Prod, as the account, before → after:
pnpm typecheckclean,next buildclean, 1,466 unit tests pass, 12 new ones intests/ads-stats-box.test.tscovering the delivered totals, the split note, and a per-range regression test that the leading bucket survives.Worth knowing separately
This makes the dashboard honest, it doesn't make the network solvent: no click has billed since 2026-07-29 because there is still only one participant on both sides. That's a business state, not a bug — flagging it because the dashboard will now say
all free backfillout loud.🤖 Generated with Claude Code