Skip to content

Distinguish daily-budget pauses from out-of-credits on /ads - #163

Merged
ralyodio merged 1 commit into
masterfrom
worktree-ads-budget-status
Jul 30, 2026
Merged

ralyodio merged 1 commit into
masterfrom
worktree-ads-budget-status

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Why

Investigating "why do ads sometimes get de-activated?" turned up two distinct mechanisms that the dashboard rendered identically — the raw ad_campaigns.status string in a badge.

writes status? resumes itself?
Daily budget reached no — stays active yes, at 00:00 UTC
Out of ad credits yes → exhausted no
  • Daily budget. ad_charge_click explicitly leaves the status column alone when spend_today + charge > daily_budget and 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 read active, so a campaign that had quietly stopped serving looked perfectly healthy.
  • Out of credits. ad_charge_click (supabase/migrations/20260707160000_ad_bidding_promo.sql:83) flips status to exhausted the moment a valid click lands and credits_balance + ad_bonus_credits < bid_credits. Nothing anywhere reactivates it — ad_apply_deposit_bonus grants 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, because credits_balance is 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 in serveAd().
  • /ads and /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."
  • Fixes the Today spend figure. Both the SQL and serveAd reset spend_today_cents lazily against spend_date, so a direct read shows the last active day's total until the next click lands. The list view now also shows it as spent / 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, and exhausted staying distinct from a budget pause. Full suite: 1168 passed / 7 skipped. tsc --noEmit clean.

npm run lint is broken on this repo independently of this change (next lint resolves its arg as a directory).

Not addressed

The one-way exhausted transition is left as-is — reactivating on deposit is a product call, not a UI fix. Worth a follow-up: setCampaignStatus only checks for a ready creative, not balance, so Activating while still under-funded goes live and gets knocked straight back to exhausted on the next click.

🤖 Generated with Claude Code

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>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio marked this pull request as ready for review July 30, 2026 18:40
@ralyodio
ralyodio merged commit b12a49b into master Jul 30, 2026
7 of 8 checks passed
@ralyodio
ralyodio deleted the worktree-ads-budget-status branch July 30, 2026 21:58
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant