Distinguish daily-budget pauses from out-of-credits on /ads - #163
Merged
Merged
Conversation
Two very different things stop a campaign from serving, and the raw
status badge made them look identical — a campaign that just stopped,
with no explanation:
* Daily budget reached. ad_charge_click leaves status alone and only
records an unbilled click; serveAd() drops the campaign from the
auction. The counter resets on the next UTC day, so it resumes on
its own. The badge still read "active", so there was nothing to see.
* Out of ad credits. ad_charge_click flips status to 'exhausted', and
nothing ever flips it back — a deposit grants credits but doesn't
touch campaign status. Needs a manual Activate.
campaignDisplayStatus() derives the distinction from the campaign row
and both ad pages now render it, saying whether serving resumes at
00:00 UTC or the advertiser has to act.
Also fixes the "Today" spend figure, which showed a stale counter from
the last active day until the next click reset it — both the SQL and
serveAd reset spend_today_cents lazily, so the reader has to apply the
spend_date check itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vu1nz Security Review0 finding(s) in PR #? No security issues found. |
ralyodio
marked this pull request as ready for review
July 30, 2026 18:40
This was referenced Jul 30, 2026
ralyodio
added a commit
that referenced
this pull request
Jul 30, 2026
…ives (#168) Every PR in this repo failed gitleaks, whatever it touched. The job ran `gitleaks detect --source .` over full history, so it resolved to a blob committed once and reported it forever — #163 and #166 both went red on a finding neither introduced. A check that is red on arrival stops being read, which is the failure mode that matters here. Pull requests now scan only base..head. History is still scanned in full on push to master and on the weekly cron, so an existing finding cannot be merged out of sight — it just stops masking new ones. Two more false-positive fingerprints. A fingerprint pins one commit, and both already-pinned lines survived later edits to their files, so each matched again under a newer SHA: the pk_test_ fixture the analyzer test asserts on, and the docs curl example that by then read `-H "Authorization: Bearer $SECRET"`. Deliberately still unpinned: the 21 findings in .env.bak-2026-07-28. Those are real credentials, they remain in history, and suppressing them before they are rotated would hide a live exposure. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio
added a commit
that referenced
this pull request
Jul 30, 2026
Two very different things stop a campaign from serving, and the raw
status badge made them look identical — a campaign that just stopped,
with no explanation:
* Daily budget reached. ad_charge_click leaves status alone and only
records an unbilled click; serveAd() drops the campaign from the
auction. The counter resets on the next UTC day, so it resumes on
its own. The badge still read "active", so there was nothing to see.
* Out of ad credits. ad_charge_click flips status to 'exhausted', and
nothing ever flips it back — a deposit grants credits but doesn't
touch campaign status. Needs a manual Activate.
campaignDisplayStatus() derives the distinction from the campaign row
and both ad pages now render it, saying whether serving resumes at
00:00 UTC or the advertiser has to act.
Also fixes the "Today" spend figure, which showed a stale counter from
the last active day until the next click reset it — both the SQL and
serveAd reset spend_today_cents lazily, so the reader has to apply the
spend_date check itself.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio
added a commit
that referenced
this pull request
Jul 30, 2026
…ives (#168) Every PR in this repo failed gitleaks, whatever it touched. The job ran `gitleaks detect --source .` over full history, so it resolved to a blob committed once and reported it forever — #163 and #166 both went red on a finding neither introduced. A check that is red on arrival stops being read, which is the failure mode that matters here. Pull requests now scan only base..head. History is still scanned in full on push to master and on the weekly cron, so an existing finding cannot be merged out of sight — it just stops masking new ones. Two more false-positive fingerprints. A fingerprint pins one commit, and both already-pinned lines survived later edits to their files, so each matched again under a newer SHA: the pk_test_ fixture the analyzer test asserts on, and the docs curl example that by then read `-H "Authorization: Bearer $SECRET"`. Deliberately still unpinned: the 21 findings in .env.bak-2026-07-28. Those are real credentials, they remain in history, and suppressing them before they are rotated would hide a live exposure. 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.
Why
Investigating "why do ads sometimes get de-activated?" turned up two distinct mechanisms that the dashboard rendered identically — the raw
ad_campaigns.statusstring in a badge.status?activeexhaustedad_charge_clickexplicitly leaves the status column alone whenspend_today + charge > daily_budgetand just records an unbilled click;serveAd()(lib/ads/serve.ts:129-138) filters the campaign out of the auction so slots fall back to the house ad. The badge still readactive, so a campaign that had quietly stopped serving looked perfectly healthy.ad_charge_click(supabase/migrations/20260707160000_ad_bidding_promo.sql:83) flips status toexhaustedthe moment a valid click lands andcredits_balance + ad_bonus_credits < bid_credits. Nothing anywhere reactivates it —ad_apply_deposit_bonusgrants credits without touching campaign status, and no cron touches ads — so topping up isn't enough. It also fires more often than you'd expect, becausecredits_balanceis one wallet shared with scans, autoblog, guest posts, apply-fix and outreach.What
lib/ads/status.ts—campaignDisplayStatus()derives a label, an explanation, whether it's serving, and whether it self-heals.isDailyBudgetReached()mirrors the eligibility filter inserveAd()./adsand/ads/[id]render the derived label plus a hint line when not serving: "Serving resumes automatically at 00:00 UTC" vs "Top up your credits, then press Activate — it won't restart on its own."serveAdresetspend_today_centslazily againstspend_date, so a direct read shows the last active day's total until the next click lands. The list view now also shows it asspent / budget.Testing
tests/ads-campaign-status.test.ts— 15 cases covering the cap boundary (exactly-fits vs one-cent-over), higher bids tripping sooner, the stale-counter rollover, andexhaustedstaying distinct from a budget pause. Full suite: 1168 passed / 7 skipped.tsc --noEmitclean.npm run lintis broken on this repo independently of this change (next lintresolves its arg as a directory).Not addressed
The one-way
exhaustedtransition is left as-is — reactivating on deposit is a product call, not a UI fix. Worth a follow-up:setCampaignStatusonly checks for a ready creative, not balance, so Activating while still under-funded goes live and gets knocked straight back toexhaustedon the next click.🤖 Generated with Claude Code