From 68aa3463ad1691e6a2875dca0974ffab65cb0c48 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 28 Jul 2026 11:10:29 +0000 Subject: [PATCH] feat(leads): charge for lead generation, then sell it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LEAD_RUN_CREDITS was defined and never read. Every campaign tick and every manual search has been running on the house, and the expensive part is not the model — it is the search calls behind contact lookup. The one-shot finder was the wider hole: it budgeted up to a hundred paid lookups per click and charged nothing for any of them. So the meter goes in before the sales page, not after it. A tick is charged once, lazily, at the first stage that actually reaches for search or a model. Charging up front would bill 288 credits a day to leave an idle campaign switched on, and the cron cannot know what a tick will do until it looks — a prospect whose scan has not landed costs nothing to skip. A run that is charged and produces nothing gives the money back; some of it is genuinely spent by then, but billing for a run with no output is the worse trade. Running out pauses the campaign and says so in the run history, which is the only place a stalled campaign gets to explain itself. The manual finder is priced per hundred leads asked for, and the search budget it buys scales with it. Sizing that budget by the request instead would let a thousand-lead run buy a hundred lookups for the price of ten. The size picker goes up to a thousand to match — it was still offering 5/10/25 against an action that already accepted 1k, so a directory page reported a fraction of itself and looked broken. The pure pricing lives in lib/credits.ts beside the other prices rather than next to the deduction, so the form can show the cost before the click without pulling a database client into the browser bundle. Then the selling: a /lead-generation page, a card on the home grid, a footer link, and a section on pricing for what credits buy besides scans. Two things the page deliberately does not claim are open tracking and automatic reply detection, because neither is built. The regression guard reads the source. A unit test of the pricing function would have passed for the entire time the price was unreferenced — the bug was that nobody called it. Co-Authored-By: Claude Opus 5 (1M context) --- app/(marketing)/lead-generation/page.tsx | 273 ++++++++++++++++++ app/(marketing)/page.tsx | 33 ++- app/(marketing)/pricing/page.tsx | 46 +++ app/actions/leads.ts | 24 +- components/leads/campaign-panel.tsx | 4 +- components/leads/lead-finder.tsx | 19 +- components/site-footer.tsx | 1 + lib/credits.ts | 34 +++ lib/outreach/billing.ts | 74 +++++ lib/outreach/runner.ts | 41 ++- .../20260729010000_campaign_run_credits.sql | 15 + tests/lead-billing.test.ts | 148 ++++++++++ 12 files changed, 696 insertions(+), 16 deletions(-) create mode 100644 app/(marketing)/lead-generation/page.tsx create mode 100644 lib/outreach/billing.ts create mode 100644 supabase/migrations/20260729010000_campaign_run_credits.sql create mode 100644 tests/lead-billing.test.ts diff --git a/app/(marketing)/lead-generation/page.tsx b/app/(marketing)/lead-generation/page.tsx new file mode 100644 index 00000000..c87f59fe --- /dev/null +++ b/app/(marketing)/lead-generation/page.tsx @@ -0,0 +1,273 @@ +import Link from "next/link"; +import { LEAD_RUN_CREDITS, CREDIT_RACK_CENTS, dollars } from "@/lib/credits"; +import { LEADS_PER_CHARGE } from "@/lib/outreach/billing"; + +export const metadata = { + title: "Lead generation & cold outreach", + description: + "Point CrawlProof at a directory or a search, and it finds the companies, finds the people, finds the addresses, and writes the email — from your own mailbox, grounded in facts you supply. 3 credits a run.", + alternates: { canonical: "/lead-generation" }, + openGraph: { + title: "Lead generation & cold outreach · CrawlProof", + description: + "Find the companies, find the people, find the addresses, write the email — from your own mailbox, grounded in facts you supply. 3 credits a run.", + url: "/lead-generation", + }, +}; + +/** + * The sales page for project-level lead generation. + * + * Every claim here is one the pipeline actually makes good on. Two things it + * deliberately does not claim: open tracking and automatic reply detection. + * Neither is built, and a lead-gen page that oversells is the exact thing the + * drafting guard exists to stop the product doing to other people's inboxes. + */ + +const LADDER = [ + { + step: "1", + title: "The site itself", + body: "Contact and about pages, footers, mailto: links, and the obfuscated addresses that are written to defeat scrapers.", + }, + { + step: "2", + title: "Team & leadership pages", + body: "A named person outperforms a shared inbox, so /our-team, /leadership and /people are read before anything is paid for.", + }, + { + step: "3", + title: "Linked PDFs", + body: "Capability statements, media kits and data-sheets routinely carry an address the page linking to them never mentions.", + }, + { + step: "4", + title: "Search", + body: "Only now does a run spend on a search call — for the people it has a name for but no way to reach.", + }, + { + step: "5", + title: "A role address, last", + body: "When a company publishes nothing at all, a conventional address like press@ is tried — and marked as a guess, not passed off as found.", + }, +]; + +export default function LeadGenerationPage() { + const runRack = LEAD_RUN_CREDITS * CREDIT_RACK_CENTS; + + return ( +
+
+

+ Project feature · Leads +

+

+ The boring half of cold outreach, done for you +

+

+ Finding who to email is most of the work and none of the fun. Point a campaign at a + directory or a search, and CrawlProof finds the companies, finds the people, finds the + addresses, and drafts the email — from your mailbox, in your words, about your thing. +

+
+ + Start a campaign + + + See pricing + +
+

+ {LEAD_RUN_CREDITS} credits a run (~{dollars(runRack)}). A run that finds nothing to do + costs nothing. +

+
+ +
+

It reads the directory like a person would

+

+ Most lead directories are a wall of JavaScript. Fetching the HTML gets you an empty shell, + which is why so much of this work ends up being done by hand. +

+
+ + + +
+
+ +
+

Directories that name people, not companies

+

+ Plenty of the best lists give you a name, a title and a location and withhold the email — + that is the product they are selling. Those are read as people: name, role, employer, + location and profile links are kept, and the address becomes something to go and find + rather than a reason to discard the lead. +

+
+ +
+

Five places to look, cheapest first

+

+ The order is deliberate. Everything free is exhausted before anything paid is tried, so a + run only spends on the prospects that genuinely publish nothing. +

+
    + {LADDER.map((l) => ( +
  1. + + {l.step} + +
    +

    {l.title}

    +

    {l.body}

    +
    +
  2. + ))} +
+
+ +
+

The email can only say what you said

+

+ A campaign carries three things: who is writing, the one small thing you are asking for, + and a list of checkable facts. Every draft is written from those and then checked against + them — a draft that states something outside the list is thrown away rather than sent. +

+
+ + + + +
+
+ +
+

Sent from your mailbox

+

+ Give it the address you already send from and its settings are discovered automatically — + the same lookup your mail client does. Replies come back to you, because the mail came + from you. +

+
+ + + +
+
+ +
+

It will refuse to email some people

+

+ Cold outreach earns its reputation one careless send at a time. Privacy, legal, abuse, + opt-out and accessibility addresses are never contacted, whatever a page publishes. A + postal address for the footer is required before live sending is possible at all, because + CAN-SPAM requires one. Anyone who asks to be left alone is suppressed across every + campaign you run, permanently. +

+
+ +
+

Your numbers, not somebody else's

+

+ Sends, replies and closes are counted at three levels: the whole project, each campaign, + and each individual run. Rates stay hidden until there is enough volume to mean anything — + one reply out of three sends is not a 33% reply rate, and showing it as one invites a + decision the sample cannot support. +

+
+ +
+

What it costs

+
+
+
+ {LEAD_RUN_CREDITS} credits + + per run (~{dollars(runRack)}) + +
+

+ A campaign run is one pass of the whole funnel: discovery, contact lookup, drafting + and sending. A run with nothing to do is not charged, so leaving a campaign switched + on between batches is free. +

+
+
+
+ {LEAD_RUN_CREDITS} credits + + per {LEADS_PER_CHARGE} leads + +
+

+ For a one-off search of a directory, priced by how much you ask for. Find nothing and + it is refunded. +

+
+
+

+ Credits are the same ones scans and articles use — no separate plan, no subscription, and + they do not expire. Volume packs bring a run down to about{" "} + {dollars(Math.round(runRack / 2))}. +

+
+ + Get started + + + Credit packs + +
+
+ +

+ Lead generation runs on projects you own. You are responsible for the mail you send and for + complying with the rules where your recipients are — CrawlProof enforces a suppression list + and a footer address, not the law. +

+
+ ); +} + +function Card({ title, body }: { title: string; body: string }) { + return ( +
+

{title}

+

{body}

+
+ ); +} diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index ddeed2ef..0b37a19a 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -106,6 +106,7 @@ export default function HomePage() { + @@ -422,14 +423,38 @@ function FeatureCard({ title, body }: { title: string; body: string }) { ); } -function ModuleCard({ emoji, title, body }: { emoji: string; title: string; body: string }) { - return ( -
+function ModuleCard({ + emoji, + title, + body, + href, +}: { + emoji: string; + title: string; + body: string; + /** Set only for modules that have a page of their own to go on to. */ + href?: string; +}) { + const inner = ( + <>
{emoji}

{title}

{body}

-
+ {href &&

Learn more →

} + + ); + // A card without somewhere to go stays a card. Wrapping every one in a link + // to nowhere would make eight of the nine look clickable and do nothing. + return href ? ( + + {inner} + + ) : ( +
{inner}
); } diff --git a/app/(marketing)/pricing/page.tsx b/app/(marketing)/pricing/page.tsx index 1c59d0b0..0786f454 100644 --- a/app/(marketing)/pricing/page.tsx +++ b/app/(marketing)/pricing/page.tsx @@ -3,6 +3,9 @@ import { CREDIT_PACKS, CREDIT_RACK_CENTS, ENGINES, + LEAD_RUN_CREDITS, + OUTREACH_CREDITS, + SCAN_CREDITS, discountPct, dollars, perCreditCents, @@ -58,6 +61,49 @@ export default function PricingPage() { })} + {/* Credits buy more than scans, and a page that only prices scans makes + every other module look like it is either free or unavailable. */} +
+

What else credits buy

+

+ One balance across the whole platform. No module has its own plan. +

+
+
+
+ {LEAD_RUN_CREDITS} credits +
+

Lead generation run

+

+ One pass of a campaign: discovery, contact lookup, drafting and sending. A run with + nothing to do is not charged.{" "} + + How it works + +

+
+
+
+ {OUTREACH_CREDITS} credit +
+

Outreach send

+

+ One email, SMS recipient or social post sent by hand, outside a campaign run. +

+
+
+
+ {SCAN_CREDITS} credits +
+

Autoblog post

+

+ A researched, illustrated, published long-form article — the same price as an + AI-model scan. +

+
+
+
+
{CREDIT_PACKS.map((p) => { const off = discountPct(p); diff --git a/app/actions/leads.ts b/app/actions/leads.ts index 7f8d547d..9a2fe8c1 100644 --- a/app/actions/leads.ts +++ b/app/actions/leads.ts @@ -17,6 +17,7 @@ import { loadAddressSettings } from "@/lib/outreach/postalAddress"; import { discoverProspects } from "@/lib/outreach/discover"; import { upsertContact } from "@/lib/outreach/contacts"; import { generatePitch } from "@/lib/outreach/generatePitch"; +import { leadRunBilling, manualRunPrice } from "@/lib/outreach/billing"; import { runEmailCampaignTick, CAMPAIGN_COLUMNS, summarize, type CampaignRow } from "@/lib/outreach/runner"; type Ok> = { ok: true } & T; @@ -73,10 +74,21 @@ export async function findLeadsAction(input: { // A directory can list hundreds of businesses; capping a one-shot run at // ten meant the form reported a fraction of a page and looked broken. const limit = Math.min(input.limit ?? 100, 1000); + // Priced per hundred leads asked for. A thousand-lead run does ten times the + // paid search of a hundred-lead one, so charging both the same would make + // the largest runs the cheapest place to spend our money. + const price = manualRunPrice(limit); + const billing = leadRunBilling(auth.userId, price.credits); + if (!(await billing.authorize())) { + return { + ok: false, + error: `Finding up to ${limit} leads costs ${price.credits} credits; not enough balance. Buy credits in Billing.`, + }; + } // Contact lookup is the part that costs money — up to two SERP calls per - // prospect that publishes no address — so a thousand-lead run gets an - // explicit ceiling rather than an unbounded bill. - const contactSearchBudget = { remaining: Math.min(limit, 100) }; + // prospect that publishes no address — so the ceiling is what the run paid + // for rather than an unbounded bill. + const contactSearchBudget = { remaining: price.contactSearches }; const { data: projectRow } = await serviceClient() .from("projects") .select("organization_id") @@ -115,6 +127,8 @@ export async function findLeadsAction(input: { } if (!found.prospects.length && !peopleRecorded) { + // Nothing to show for it, so nothing to charge for it. + await billing.refund(); return { ok: false, error: found.errors.join("; ") || "No businesses found for that search." }; } @@ -147,8 +161,8 @@ export async function findLeadsAction(input: { added, scanning, note: peopleRecorded - ? `${added} leads added, ${peopleRecorded} people recorded.` - : `${added} leads added.`, + ? `${added} leads added, ${peopleRecorded} people recorded — ${price.credits} credits.` + : `${added} leads added — ${price.credits} credits.`, }; } diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx index 5aeea23d..5e411ed9 100644 --- a/components/leads/campaign-panel.tsx +++ b/components/leads/campaign-panel.tsx @@ -8,6 +8,7 @@ import { saveCampaignAction, toggleCampaignAction, } from "@/app/actions/leads"; +import { LEAD_RUN_CREDITS } from "@/lib/credits"; export type CampaignRun = { ran_at: string; @@ -202,7 +203,8 @@ export function CampaignPanel({

Campaigns

Runs every 15 minutes: finds leads, scans them, writes drafts. Only sends when you turn - auto-send on. + auto-send on. {LEAD_RUN_CREDITS} credits per run that does work — a run with nothing to + do is free, so leaving a campaign on between batches costs nothing.

{mode === "query" ? "Finds businesses and looks up a contact address for each. It does not scan them." - : "Every outbound link on that page becomes a candidate. Platforms and aggregators are filtered out."} + : "Every outbound link on that page becomes a candidate. Platforms and aggregators are filtered out."}{" "} + Charged on results — a search that finds nothing is refunded.

{note &&

{note}

} {error &&

{error}

} diff --git a/components/site-footer.tsx b/components/site-footer.tsx index 3b2a2dad..0770679c 100644 --- a/components/site-footer.tsx +++ b/components/site-footer.tsx @@ -14,6 +14,7 @@ export function SiteFooter() {
Product
  • Pricing
  • +
  • Lead generation
  • Slop Score
  • Get guide
  • Recent scans
  • diff --git a/lib/credits.ts b/lib/credits.ts index 2842d444..e07f1fed 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -35,6 +35,40 @@ export const OUTREACH_CREDITS = 1; */ export const LEAD_RUN_CREDITS = 3; +/** Leads covered by one charge on the manual finder. */ +export const LEADS_PER_CHARGE = 100; + +/** + * Paid search lookups a single charge buys. + * + * Matches the per-tick ceiling the price above was measured against, so a + * manual run and an automated one cost the same per unit of the thing that + * actually spends money. Without it a thousand-lead run would buy a hundred + * lookups for the price of ten. + */ +export const CONTACT_SEARCHES_PER_CHARGE = 10; + +/** + * What a manual find-leads run costs, and the search budget it buys. + * + * Priced per hundred leads asked for rather than per lead: the request size is + * what the user chooses, and it is the only figure available before the run + * starts. Rounded up, so asking for one lead is not free. + * + * Pure, and kept here rather than beside the deduction, so the form can show + * the price without pulling a database client into the browser bundle. + */ +export function manualRunPrice(limit: number): { + credits: number; + contactSearches: number; +} { + const blocks = Math.max(1, Math.ceil(limit / LEADS_PER_CHARGE)); + return { + credits: blocks * LEAD_RUN_CREDITS, + contactSearches: blocks * CONTACT_SEARCHES_PER_CHARGE, + }; +} + export type CreditPack = { id: string; label: string; diff --git a/lib/outreach/billing.ts b/lib/outreach/billing.ts new file mode 100644 index 00000000..412a18d2 --- /dev/null +++ b/lib/outreach/billing.ts @@ -0,0 +1,74 @@ +// Charging for lead generation. +// +// The price existed as a constant for a while before anything read it, which +// meant every campaign tick and every one-shot search ran on the house — and +// the expensive part is not the AI, it is the search calls behind contact +// lookup. This is the meter. +// +// Two rules shape it: +// +// A tick is billed only when it is about to do billable work. The cron fires +// every fifteen minutes whether or not a campaign has anything to do, so +// charging per tick would cost 288 credits a day to keep an idle campaign +// switched on. The gate below is therefore lazy: nothing is charged until a +// stage actually reaches for search or a model. +// +// And a tick is billed once, not per stage. Discovery, research and drafting +// inside one run are one unit of work from the user's side, and metering them +// separately would make the cost of leaving a campaign on unpredictable. + +import { LEAD_RUN_CREDITS } from "@/lib/credits"; + +export { manualRunPrice, LEADS_PER_CHARGE, CONTACT_SEARCHES_PER_CHARGE } from "@/lib/credits"; +import { consumeCredit, refundCredit } from "@/lib/rateLimit"; + +export type RunBilling = { + /** + * Charge for this run if it has not been charged yet. + * + * Returns false when the balance will not cover it, which callers treat as + * "stop here" rather than "carry on unbilled". + */ + authorize: () => Promise; + /** True once the run has been paid for. */ + charged: () => boolean; + /** True when a charge was attempted and the balance would not cover it. */ + declined: () => boolean; + /** Hand the money back — for a run that turned out to do nothing. */ + refund: () => Promise; +}; + +/** + * A single charge, taken at most once, on first use. + * + * Deliberately not an up-front charge on the whole tick: what a tick will do + * is not knowable before it looks, and a prospect whose scan has not landed + * costs nothing to skip. + */ +export function leadRunBilling(ownerId: string, credits = LEAD_RUN_CREDITS): RunBilling { + let state: "unbilled" | "billed" | "declined" = "unbilled"; + + return { + async authorize() { + if (state === "billed") return true; + // One decline ends the run. Retrying per stage would hammer the balance + // check for a campaign that has simply run out. + if (state === "declined") return false; + const res = await consumeCredit(ownerId, credits); + state = res.ok ? "billed" : "declined"; + return res.ok; + }, + charged: () => state === "billed", + declined: () => state === "declined", + async refund() { + if (state !== "billed") return; + await refundCredit(ownerId, credits); + state = "unbilled"; + }, + }; +} + +/** What the run history says when a campaign cannot pay for a tick. */ +export function outOfCreditsNote(credits = LEAD_RUN_CREDITS): string { + return `out of credits — a run costs ${credits} credit${credits === 1 ? "" : "s"}; top up in Billing to resume`; +} diff --git a/lib/outreach/runner.ts b/lib/outreach/runner.ts index ff02e2e5..ec35f0ab 100644 --- a/lib/outreach/runner.ts +++ b/lib/outreach/runner.ts @@ -24,6 +24,8 @@ import { type ProspectRow, } from "./pipeline"; import { nextStepReadyAt, type OutreachStep } from "./cold"; +import { leadRunBilling, outOfCreditsNote } from "./billing"; +import { LEAD_RUN_CREDITS } from "@/lib/credits"; export type CampaignRow = { id: string; @@ -68,6 +70,8 @@ export type TickResult = { autoSend: boolean; /** Seed URLs sitting behind a sign-in with no stored credential. */ awaitingAuth: string[]; + /** Credits this tick actually charged. Zero when it found nothing to do. */ + creditsSpent: number; skipped: string[]; errors: string[]; }; @@ -94,6 +98,7 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise => { + if (await billing.authorize()) return true; + if (!result.errors.includes(outOfCreditsNote())) result.errors.push(outOfCreditsNote()); + return false; + }; + const { data: pool } = await sb .from("outreach_prospects") .select(PROSPECT_COLUMNS) @@ -131,6 +148,8 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise ["new", "researched", "drafted"].includes(p.status)).length; - if (liveCount < campaign.target_pipeline) { + // Discovery is the most expensive stage — several search calls before a + // single prospect exists — so it is gated like the rest. + if (liveCount < campaign.target_pipeline && (await canSpend())) { const want = Math.min(campaign.target_pipeline - liveCount, MAX_DISCOVER_PER_TICK); const { data: projectRow } = await sb .from("projects") @@ -285,6 +311,15 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise ({ + consumeCredit: (...a: unknown[]) => consumeCredit(...a), + refundCredit: (...a: unknown[]) => refundCredit(...a), +})); + +const { leadRunBilling, outOfCreditsNote } = await import("@/lib/outreach/billing"); + +beforeEach(() => { + consumeCredit.mockReset().mockResolvedValue({ ok: true }); + refundCredit.mockReset().mockResolvedValue(undefined); +}); + +describe("manualRunPrice", () => { + it("charges one unit for a run inside the first block", () => { + expect(manualRunPrice(LEADS_PER_CHARGE).credits).toBe(LEAD_RUN_CREDITS); + expect(manualRunPrice(1).credits).toBe(LEAD_RUN_CREDITS); + }); + + it("never gives a run away, however small", () => { + // Rounding down would make a one-lead run free, and a one-lead run still + // pays for a search. + expect(manualRunPrice(0).credits).toBe(LEAD_RUN_CREDITS); + }); + + it("scales with the size asked for", () => { + // Ten times the leads is ten times the paid search behind them; charging + // the same would make the biggest runs the cheapest place to spend on us. + expect(manualRunPrice(1000).credits).toBe(10 * LEAD_RUN_CREDITS); + }); + + it("rounds a part-block up", () => { + expect(manualRunPrice(LEADS_PER_CHARGE + 1).credits).toBe(2 * LEAD_RUN_CREDITS); + }); + + it("buys search budget in proportion to the charge", () => { + const small = manualRunPrice(LEADS_PER_CHARGE); + const large = manualRunPrice(LEADS_PER_CHARGE * 10); + expect(large.contactSearches / small.contactSearches).toBe( + large.credits / small.credits, + ); + }); +}); + +describe("leadRunBilling", () => { + it("charges once however many stages ask", async () => { + // A tick's stages each check before spending; the user bought one run. + const b = leadRunBilling("owner-1"); + expect(await b.authorize()).toBe(true); + expect(await b.authorize()).toBe(true); + expect(await b.authorize()).toBe(true); + expect(consumeCredit).toHaveBeenCalledTimes(1); + expect(consumeCredit).toHaveBeenCalledWith("owner-1", LEAD_RUN_CREDITS); + }); + + it("does not charge a tick that never asks", async () => { + // The cron fires every fifteen minutes. Billing an idle campaign per tick + // would cost 288 credits a day to leave switched on. + leadRunBilling("owner-1"); + expect(consumeCredit).not.toHaveBeenCalled(); + }); + + it("stops asking once the balance has declined", async () => { + consumeCredit.mockResolvedValue({ ok: false }); + const b = leadRunBilling("owner-1"); + expect(await b.authorize()).toBe(false); + expect(await b.authorize()).toBe(false); + // Retrying per stage would hammer the balance check for a campaign that + // has simply run out. + expect(consumeCredit).toHaveBeenCalledTimes(1); + expect(b.declined()).toBe(true); + expect(b.charged()).toBe(false); + }); + + it("refunds what it charged", async () => { + const b = leadRunBilling("owner-1"); + await b.authorize(); + await b.refund(); + expect(refundCredit).toHaveBeenCalledWith("owner-1", LEAD_RUN_CREDITS); + expect(b.charged()).toBe(false); + }); + + it("refunds nothing when nothing was charged", async () => { + // A refund path that fires without a charge is free credits. + const b = leadRunBilling("owner-1"); + await b.refund(); + expect(refundCredit).not.toHaveBeenCalled(); + }); + + it("does not refund twice", async () => { + const b = leadRunBilling("owner-1"); + await b.authorize(); + await b.refund(); + await b.refund(); + expect(refundCredit).toHaveBeenCalledTimes(1); + }); + + it("honours a custom price, for runs charged by size", async () => { + const b = leadRunBilling("owner-1", 30); + await b.authorize(); + expect(consumeCredit).toHaveBeenCalledWith("owner-1", 30); + }); + + it("names the price in the out-of-credits note", async () => { + // It lands in the run history, which is the only place a paused campaign + // gets to explain itself. + expect(outOfCreditsNote()).toContain(String(LEAD_RUN_CREDITS)); + expect(outOfCreditsNote()).toMatch(/billing/i); + }); +}); + +describe("the meter is actually connected", () => { + // The price sat in the source as an unreferenced constant for a while, so + // every campaign tick and every manual search ran free. A unit test of the + // pricing function would have passed the whole time it was broken — the bug + // was that nobody called it. These read the source instead. + const read = async (p: string) => + (await import("node:fs")).readFileSync(new URL(`../${p}`, import.meta.url), "utf8"); + + it("charges the campaign tick", async () => { + const src = await read("lib/outreach/runner.ts"); + expect(src).toContain("leadRunBilling"); + }); + + it("gates discovery, the most expensive stage, on being able to pay", async () => { + const src = await read("lib/outreach/runner.ts"); + const discovery = src.slice(src.indexOf("---- 4. Top the funnel up")); + expect(discovery.slice(0, 400)).toMatch(/canSpend\(\)/); + }); + + it("charges the manual finder", async () => { + const src = await read("app/actions/leads.ts"); + expect(src).toContain("manualRunPrice"); + expect(src).toContain("leadRunBilling"); + }); + + it("sizes the manual search budget by what was paid for", async () => { + // Not by the request size: that is what let a thousand-lead run buy a + // hundred lookups for the price of ten. + const src = await read("app/actions/leads.ts"); + expect(src).toContain("remaining: price.contactSearches"); + }); +});