From 659ec01582862c4ff227ab110242431ec2c6dfa7 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Thu, 30 Jul 2026 18:26:18 +0000
Subject: [PATCH] Distinguish daily-budget pauses from out-of-credits on /ads
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
app/(app)/ads/[id]/page.tsx | 17 ++++-
app/(app)/ads/page.tsx | 26 +++++--
lib/ads/status.ts | 100 +++++++++++++++++++++++++
tests/ads-campaign-status.test.ts | 117 ++++++++++++++++++++++++++++++
4 files changed, 252 insertions(+), 8 deletions(-)
create mode 100644 lib/ads/status.ts
create mode 100644 tests/ads-campaign-status.test.ts
diff --git a/app/(app)/ads/[id]/page.tsx b/app/(app)/ads/[id]/page.tsx
index f8760918..6b057712 100644
--- a/app/(app)/ads/[id]/page.tsx
+++ b/app/(app)/ads/[id]/page.tsx
@@ -6,6 +6,7 @@ import { AdPreview } from "@/components/ads/ad-preview";
import { CampaignActions, RegenerateButton } from "@/components/ads/campaign-actions";
import { CampaignTrend } from "@/components/ads/campaign-trend";
import { getCampaignDailySeries } from "@/lib/ads/series";
+import { campaignDisplayStatus, spendTodayCents, utcToday } from "@/lib/ads/status";
export const metadata = { title: "Campaign" };
@@ -47,7 +48,7 @@ export default async function CampaignDetailPage({
const { data: campaign } = await supabase
.from("ad_campaigns")
.select(
- "id, name, destination_url, destination_domain, daily_budget_cents, bid_credits, status, ref_slug, created_at, spend_today_cents, total_spent_cents",
+ "id, name, destination_url, destination_domain, daily_budget_cents, bid_credits, status, ref_slug, created_at, spend_today_cents, spend_date, total_spent_cents",
)
.eq("id", id)
.eq("owner_id", user.id)
@@ -73,6 +74,8 @@ export default async function CampaignDetailPage({
const impressions = (stats?.impressions as number) ?? 0;
const clicks = (stats?.clicks as number) ?? 0;
const daily = series.get(id) ?? [];
+ const today = utcToday();
+ const display = campaignDisplayStatus(campaign, today);
const creatives: (AdCreative & { id: string })[] = ((creativeRows as CreativeRow[]) ?? []).map(
(r) => ({
@@ -115,7 +118,9 @@ export default async function CampaignDetailPage({
);
})}
diff --git a/lib/ads/status.ts b/lib/ads/status.ts
new file mode 100644
index 00000000..af89d16f
--- /dev/null
+++ b/lib/ads/status.ts
@@ -0,0 +1,100 @@
+// Advertiser-facing campaign status. The stored `ad_campaigns.status` doesn't
+// tell the whole story, because two very different things stop a campaign from
+// serving and only one of them writes to that column:
+//
+// * Daily budget reached — status stays 'active'. ad_charge_click leaves the
+// column alone and only records an unbilled click; serveAd() filters the
+// campaign out of the auction. The counter resets on the next UTC day, so
+// the campaign resumes on its own with no advertiser action.
+// * Out of ad credits — ad_charge_click flips status to 'exhausted'. Nothing
+// ever flips it back (a deposit grants credits but doesn't touch campaign
+// status), so this one needs a manual Activate.
+//
+// Rendering the raw column made those two look identical — a stalled campaign
+// with no explanation. campaignDisplayStatus() derives the distinction so the
+// dashboard can say which one happened and whether it self-heals.
+
+import { CREDIT_CENTS, DEFAULT_BID_CREDITS } from "./pricing";
+
+export type CampaignBudgetFields = {
+ status: string;
+ daily_budget_cents: number;
+ spend_today_cents?: number | null;
+ spend_date?: string | null;
+ bid_credits?: number | null;
+};
+
+export type CampaignDisplayStatus = {
+ /** Badge text. */
+ label: string;
+ /** One-line explanation of why it's in this state. */
+ hint: string;
+ /** Eligible to fill a slot right now. */
+ serving: boolean;
+ /** Comes back without the advertiser doing anything. */
+ resumesAutomatically: boolean;
+};
+
+/** The day boundary the budget counter resets on — UTC, matching SQL current_date. */
+export function utcToday(now: Date = new Date()): string {
+ return now.toISOString().slice(0, 10);
+}
+
+// Spend that counts against today's cap. The stored counter is only meaningful
+// when spend_date is today; on a new UTC day it's last-active-day's total that
+// nothing has zeroed yet (both serveAd and ad_charge_click reset it lazily).
+export function spendTodayCents(c: CampaignBudgetFields, today: string = utcToday()): number {
+ return c.spend_date === today ? (c.spend_today_cents ?? 0) : 0;
+}
+
+/** True when one more click at this campaign's bid would exceed the daily cap. */
+export function isDailyBudgetReached(
+ c: CampaignBudgetFields,
+ today: string = utcToday(),
+): boolean {
+ const bid = c.bid_credits ?? DEFAULT_BID_CREDITS;
+ // Mirrors the eligibility filter in serveAd() — keep the two in step.
+ return spendTodayCents(c, today) + bid * CREDIT_CENTS > c.daily_budget_cents;
+}
+
+export function campaignDisplayStatus(
+ c: CampaignBudgetFields,
+ today: string = utcToday(),
+): CampaignDisplayStatus {
+ if (c.status === "exhausted") {
+ return {
+ label: "out of credits",
+ hint: "Ran out of ad credits, so the campaign stopped. Top up your credits, then press Activate — it won't restart on its own.",
+ serving: false,
+ resumesAutomatically: false,
+ };
+ }
+
+ if (c.status !== "active") {
+ return {
+ label: c.status,
+ hint:
+ c.status === "paused"
+ ? "Paused by you. Press Activate to resume."
+ : "Not live yet. Press Activate to start serving.",
+ serving: false,
+ resumesAutomatically: false,
+ };
+ }
+
+ if (isDailyBudgetReached(c, today)) {
+ return {
+ label: "daily budget reached",
+ hint: "Today's budget is spent. Serving resumes automatically at 00:00 UTC — raise the daily budget to keep going sooner.",
+ serving: false,
+ resumesAutomatically: true,
+ };
+ }
+
+ return {
+ label: "active",
+ hint: "Live and eligible to serve.",
+ serving: true,
+ resumesAutomatically: true,
+ };
+}
diff --git a/tests/ads-campaign-status.test.ts b/tests/ads-campaign-status.test.ts
new file mode 100644
index 00000000..e271e283
--- /dev/null
+++ b/tests/ads-campaign-status.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it } from "vitest";
+import {
+ campaignDisplayStatus,
+ isDailyBudgetReached,
+ spendTodayCents,
+ utcToday,
+ type CampaignBudgetFields,
+} from "@/lib/ads/status";
+import { CREDIT_CENTS, DEFAULT_BID_CREDITS } from "@/lib/ads/pricing";
+
+// The two ways a campaign stops serving look identical in the raw status
+// column, so these cases pin down which one the dashboard reports — and, more
+// importantly, whether it tells the advertiser to wait or to take action.
+
+const TODAY = "2026-07-30";
+
+const campaign = (over: Partial = {}): CampaignBudgetFields => ({
+ status: "active",
+ daily_budget_cents: 500,
+ spend_today_cents: 0,
+ spend_date: TODAY,
+ bid_credits: DEFAULT_BID_CREDITS,
+ ...over,
+});
+
+describe("spendTodayCents", () => {
+ it("counts spend recorded today", () => {
+ expect(spendTodayCents(campaign({ spend_today_cents: 320 }), TODAY)).toBe(320);
+ });
+
+ it("ignores a stale counter from an earlier UTC day", () => {
+ // ad_charge_click/serveAd both reset lazily, so the stored column still
+ // holds yesterday's total until the next click lands.
+ expect(
+ spendTodayCents(campaign({ spend_today_cents: 500, spend_date: "2026-07-29" }), TODAY),
+ ).toBe(0);
+ });
+
+ it("treats a never-spent campaign as zero", () => {
+ expect(spendTodayCents(campaign({ spend_today_cents: null, spend_date: null }), TODAY)).toBe(0);
+ });
+});
+
+describe("isDailyBudgetReached", () => {
+ it("is false while one more click still fits under the cap", () => {
+ // 500c cap, 480c spent, 4-credit bid = 20c → exactly fits.
+ expect(isDailyBudgetReached(campaign({ spend_today_cents: 480 }), TODAY)).toBe(false);
+ });
+
+ it("is true once the next click would exceed the cap", () => {
+ expect(isDailyBudgetReached(campaign({ spend_today_cents: 481 }), TODAY)).toBe(true);
+ });
+
+ it("trips sooner for a higher bid", () => {
+ const c = campaign({ spend_today_cents: 400, bid_credits: 40 }); // 40 × 5c = 200c
+ expect(isDailyBudgetReached(c, TODAY)).toBe(true);
+ });
+
+ it("falls back to the default bid when the column is missing", () => {
+ const c = campaign({ spend_today_cents: 500 - DEFAULT_BID_CREDITS * CREDIT_CENTS, bid_credits: null });
+ expect(isDailyBudgetReached(c, TODAY)).toBe(false);
+ });
+
+ it("clears on the next UTC day even with a maxed-out stale counter", () => {
+ const c = campaign({ spend_today_cents: 500, spend_date: "2026-07-29" });
+ expect(isDailyBudgetReached(c, TODAY)).toBe(false);
+ });
+});
+
+describe("campaignDisplayStatus", () => {
+ it("reports a live campaign as active and serving", () => {
+ const d = campaignDisplayStatus(campaign(), TODAY);
+ expect(d.label).toBe("active");
+ expect(d.serving).toBe(true);
+ });
+
+ it("distinguishes the daily cap as a self-healing pause", () => {
+ const d = campaignDisplayStatus(campaign({ spend_today_cents: 500 }), TODAY);
+ expect(d.label).toBe("daily budget reached");
+ expect(d.serving).toBe(false);
+ expect(d.resumesAutomatically).toBe(true);
+ expect(d.hint).toMatch(/00:00 UTC/);
+ });
+
+ it("reports exhausted credits as needing manual action", () => {
+ const d = campaignDisplayStatus(campaign({ status: "exhausted" }), TODAY);
+ expect(d.label).toBe("out of credits");
+ expect(d.serving).toBe(false);
+ expect(d.resumesAutomatically).toBe(false);
+ expect(d.hint).toMatch(/Activate/);
+ });
+
+ it("keeps exhausted distinct from the daily cap even when under budget", () => {
+ // Plenty of budget left — the stop is the empty wallet, not the cap.
+ const d = campaignDisplayStatus(campaign({ status: "exhausted", spend_today_cents: 0 }), TODAY);
+ expect(d.label).toBe("out of credits");
+ });
+
+ it("passes through paused and draft", () => {
+ expect(campaignDisplayStatus(campaign({ status: "paused" }), TODAY).label).toBe("paused");
+ expect(campaignDisplayStatus(campaign({ status: "draft" }), TODAY).label).toBe("draft");
+ });
+
+ it("does not report a budget pause for a campaign that isn't live", () => {
+ const d = campaignDisplayStatus(campaign({ status: "paused", spend_today_cents: 500 }), TODAY);
+ expect(d.label).toBe("paused");
+ expect(d.resumesAutomatically).toBe(false);
+ });
+});
+
+describe("utcToday", () => {
+ it("uses the UTC calendar day, matching SQL current_date", () => {
+ // 23:30 UTC-adjacent local times must not roll the day forward/back.
+ expect(utcToday(new Date("2026-07-30T23:30:00Z"))).toBe("2026-07-30");
+ expect(utcToday(new Date("2026-07-31T00:10:00Z"))).toBe("2026-07-31");
+ });
+});