Skip to content

feat(leads): price a run, cap its variable cost, and give it a real funnel - #140

Merged
ralyodio merged 1 commit into
masterfrom
feat/lead-credits-funnel-contacts
Jul 28, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/lead-credits-funnel-contacts

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Four pieces of the same thing: making lead generation sellable, measurable, and deduplicated.

Pricing — measured, not guessed

At the per-tick caps (5 SERP queries, 8 researched, 5 drafts):

Component Ceiling Typical
ValueSERP $0.038 $0.012
Haiku 4.5 drafts ($1/$5 per MTok) $0.009 $0.005
Renders + fetches $0.002 $0.002
Total ~$0.049 ~$0.019

Search is ~4/5 of the cost; the model is under 1/5 — pricing off AI cost alone would have undercharged ~5×.

LEAD_RUN_CREDITS = 3 → 15¢ at rack, 7.5¢ on the deepest pack, so margin holds at every tier. Two credits would have been exactly break-even for anyone on the 100-scan pack.

Charged when a tick spends, not per tick. The cron fires every 15 minutes — a flat per-tick charge would bill an idle campaign 288 credits/day for doing nothing.

Cost ceiling on the contact fallback

The search-based fallback (#133) scales with how many prospects publish no address, making it the most variable cost in a run. Now capped at 10 lookups per tick, shared across every prospect the tick researches.

Funnel: project, campaign, and run level

Reports what happened, not what a benchmark predicts.

  • Rates withheld below 20 sends. One reply in three is not a 33% reply rate, and rendering it as one invites a decision the sample can't support.
  • Close rate divides by replies, not by everyone contacted — a deal comes out of a conversation.
  • Dry runs excluded from sends; nobody received them.

Outcomes were unrecordable

The replied / won statuses existed in the schema and rendered in the UI, but no code ever set them — so a measured reply rate would have read 0% forever. markLeadOutcomeAction fixes that, which is what makes the funnel worth showing at all.

outreach_contacts — one record per person

outreach_prospects is unique per (project_id, channel, target_key), so the same person became a separate row in every project that found them, with separate details and nothing noticing when two campaigns were about to email them the same week.

Discovery merges, never overwrites: a scraped company name must not replace one a human typed, and a later conflicting value lands in alternates rather than being discarded. Carries niche, industry, company_site, source_url, and country for segmentation and provenance, plus an org-wide do_not_contact.

RLS bug caught pre-apply: written as m.organization_id = organization_id, the bare name resolves to the subquery's own table — the predicate becomes a tautology and every member of any org can read every contact. Now qualified.

Checks

  • tsc --noEmit clean
  • 811/811 tests pass
  • production build compiles
  • migration applied to prod

🤖 Generated with Claude Code

…unnel

Four pieces of the same thing: making lead generation a feature that can
be sold, measured, and deduplicated.

Pricing is measured rather than guessed. At the per-tick caps a run costs
about 4.9c at the ceiling and nearer 2c in ordinary use, of which search
is roughly four fifths and the model under a fifth — pricing off AI cost
alone would have undercharged by about five times. Three credits is 15c
at rack and 7.5c on the deepest pack, so it keeps a margin at every tier;
two would have been exactly break-even for anyone on the largest pack.

It is charged when a tick spends, not per tick. The cron fires every
fifteen minutes, so a flat per-tick charge would bill an idle campaign
288 credits a day for doing nothing.

The search-based contact fallback now has a per-run ceiling like every
other stage. It is the most variable cost in a tick, because it scales
with how many prospects publish no address.

The funnel reports what happened rather than what a benchmark predicts,
at project, campaign and run level. Rates are withheld below twenty sends:
one reply in three is not a thirty-three percent reply rate, and drawing
it as one invites a decision the sample cannot support. Close rate
divides by replies rather than by everyone contacted, because a deal
comes out of a conversation.

Nothing could previously record an outcome. The statuses existed and the
UI rendered them, but no code ever moved a prospect to replied or won, so
a measured reply rate would have been structurally zero forever. Marking
one is now possible, which is what makes the funnel worth showing.

outreach_contacts is the source of truth for who someone is. Prospects
are unique per project, so the same person became a separate row in every
project that found them, and nothing noticed when two campaigns were about
to email them in the same week. Discovery merges into a contact and never
overwrites: a scraped company name must not replace one a human typed, and
a later conflicting value is kept in `alternates` rather than discarded.

The read policy qualifies the outer column. Written as `m.organization_id
= organization_id` the bare name resolves to the subquery's own table, the
predicate is a tautology, and every member of any organization can read
every contact in the table.

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 merged commit 0d3d7bd into master Jul 28, 2026
8 checks passed
@ralyodio
ralyodio deleted the feat/lead-credits-funnel-contacts branch July 28, 2026 04:01
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