diff --git a/lib/alerts/valueserp.ts b/lib/alerts/valueserp.ts index efa7778..510948a 100644 --- a/lib/alerts/valueserp.ts +++ b/lib/alerts/valueserp.ts @@ -72,6 +72,18 @@ export function resetSerpCreditCooldown(): void { outOfCreditsUntil = 0; } +/** + * Is the account known to be out of credits right now? + * + * Callers that judge their own searches by what came back need this to tell + * "these queries are tapped out" from "we could not run them at all". Without + * it, a month-end 402 looks identical to an exhausted query list, and the + * back-off punishes queries that were never given a chance. + */ +export function serpCreditsExhausted(): boolean { + return Date.now() < outOfCreditsUntil; +} + /** * Run one ValueSERP search. Returns billable `calls` even on an empty result * set so the caller can debit the budget accurately. Retries once on a diff --git a/lib/outreach/runner.ts b/lib/outreach/runner.ts index 1e34c61..f8637b9 100644 --- a/lib/outreach/runner.ts +++ b/lib/outreach/runner.ts @@ -28,6 +28,7 @@ import { leadRunBilling, outOfCreditsNote } from "./billing"; import { recordDiscoveredPeople } from "./contacts"; import { sweepIntent } from "./intentSources"; import { describeIntent } from "./intent"; +import { serpCreditsExhausted } from "@/lib/alerts/valueserp"; import { LEAD_RUN_CREDITS } from "@/lib/credits"; import { sendIntentAlertEmail } from "@/lib/email"; import { siteBase } from "./pipeline"; @@ -447,7 +448,15 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise { expect(fetchMock).toHaveBeenCalledTimes(1); }); + // The back-off asks "did these queries produce anything?". An empty plan + // means they never ran, so it must be able to tell the two apart — otherwise + // a month-end 402 rests every campaign for a day and recovery lands well + // after the credits do. + it("reports the exhausted state so the back-off can discount the pass", async () => { + globalThis.fetch = vi.fn(async () => new Response("", { status: 402 })) as unknown as typeof fetch; + const { searchSerp, serpCreditsExhausted, resetSerpCreditCooldown } = await import( + "@/lib/alerts/valueserp" + ); + resetSerpCreditCooldown(); + + expect(serpCreditsExhausted()).toBe(false); + await searchSerp({ query: "web development agencies", recency: "any" }); + expect(serpCreditsExhausted()).toBe(true); + + resetSerpCreditCooldown(); + expect(serpCreditsExhausted()).toBe(false); + }); + + it("does not report exhaustion for an ordinary failure", async () => { + globalThis.fetch = vi.fn(async () => new Response("", { status: 400 })) as unknown as typeof fetch; + const { searchSerp, serpCreditsExhausted, resetSerpCreditCooldown } = await import( + "@/lib/alerts/valueserp" + ); + resetSerpCreditCooldown(); + + const res = await searchSerp({ query: "bad query", recency: "any" }); + expect(res.ok).toBe(false); + expect(serpCreditsExhausted()).toBe(false); + }); + it("never bills a call it did not make", async () => { globalThis.fetch = vi.fn(async () => new Response("", { status: 402 })) as unknown as typeof fetch; const { searchSerp, resetSerpCreditCooldown } = await import("@/lib/alerts/valueserp");