Tour Dash
Report · 5 September 2026

The lead marketplace, live

Selling a lead was a form nothing linked to, with a submit button that said "nothing was posted". It now writes to real tables, charges the buyer through Stripe, holds the money for a 72-hour dispute window, and pays the seller. The contact sits behind its own row-level policy and is returned only to a buyer whose card went through. This report is what was built, how the money moves, and the two things that have to be run on the live database before the first real lead sells.

Scope Leads board, Sell a lead, two Edge Functions, the webhook, the command center Verified Bought, sold, collected and posted in the demo at 1440 and 390 Live at tour-dash.pages.dev/leads
01

The short version

A lead is the one thing on Tour Dash where the person who posts is the one who gets paid. Every other charge runs poster-pays. This is the first rail where the responder pays, and it has its own checkout, its own release, and its own dispute path because of it.

02

What it looks like

The Leads page: a heading reading Leads with a Sell a lead button, Buy and Selling tabs, type filters, a sort, and a For sale list of four leads as rows showing type, exclusivity, city, price band, a line of motivation, timeline, capture date and source, with the price and seller on the right. One row reads Unlocked with View contact. A rail on the right shows 4 leads on the board from $20, 3 exclusive, consent on file for 4 of 4, and a 72-hour dispute window.
The board, with the button that was missing and the tab that did not exist.
The unlock dialog after purchase: the lead's shape at the top, then a panel with the contact's name, phone and email and a note, a line saying 72 hours to dispute this as dead, wrong or duplicated, a text box asking what was wrong with the lead, and Close and Dispute this lead buttons. A toast reads Contact unlocked, $75.00 charged.
After paying. The contact comes from its own table under its own policy; the dispute box is the buyer's one write.
The Selling tab: a Sales list with two rows, one sold yesterday with the window closing in 3 days and one sold five days ago marked paid out today, then a Your leads list with a sold exclusive lead and a shared lead showing 1 of 2 sold with a Withdraw button. A rail reads Paid out $37.60, In the window $55.20, On the board 1 lead, Sold 2 times. A toast reads Paid out, $37.60 is on its way to your bank through Stripe.
The seller's side. Collect appears only once the window has closed, and it is a button because a Stripe transfer can fail on an unsettled balance and the seller should see that answer.
The Sell a lead form, filled in: They are a Buyer, Timeline within 30 days, Where they want to be Summit NJ, price range $700k to $850k, where the lead came from, a motivation line, and a consent switch. Section headings read Who they are and Everyone on the board sees this part.
The public half of the form. The contact section follows it, locked, then the price with the split shown before the button.
03

How the money moves

StepWhat happensWhere
PostThe seller's listing and contact are written. Price is frozen by a trigger; the sold count and the seller cannot be changed by a signed-in user.lead_listings, lead_contacts
BuyA pending purchase row is created with the split ($2 + 12%) frozen on it, and the buyer goes to Stripe Checkout. The seller must have payouts enabled or the card is refused.stripe-lead-checkout
PaidThe webhook marks the purchase paid, counts the sale, marks the lead sold out if it hit its cap, writes the buyer's payment line and the seller's pending earning line, and notifies the seller.stripe-webhook
UnlockThe contact policy returns the row to a buyer with a paid, disputed or released purchase. Refunded buyers lose access with the money.row-level policy on lead_contacts
DisputeInside 72 hours the buyer calls dispute_lead_purchase(). It checks the buyer, the status and the clock, sets the row to disputed, and notifies the seller. Nothing is refunded automatically.database function
CollectAfter 72 hours the seller (or an admin) calls the release. It transfers the payout to the seller's connected account, idempotent on the purchase id, and completes their earning line.stripe-lead-release
RefundIssued from the Stripe dashboard. The webhook records it, the buyer's access closes, and the seller's earning line is marked failed so the ledger shows what happened.stripe-webhook

The fee curve is the one already in fees.ts: $2 plus 12%, which is 20% on a $25 lead and tapers to 13% at $150. The command center now sums lead fees beside job fees, shows leads as live in the marketplace card, lists open disputes in the queue, and has a Lead sales tab.

04

What has to be run live

The page is deployed and the demo walks the whole loop. On the live site, posting a lead will fail until the tables exist, and buying will fail until the functions are deployed.

-- 1. In the Supabase SQL editor
supabase/lead-marketplace-migration.sql

# 2. From ~/tour-dash, after `npx supabase login`
npx supabase functions deploy stripe-lead-checkout
npx supabase functions deploy stripe-lead-release
npx supabase functions deploy stripe-webhook --no-verify-jwt
05

What it does not do yet

06

Verification

07

Files

FileWhat changed
supabase/lead-marketplace-migration.sqlNew. lead_listings, lead_contacts, lead_purchases, the protect trigger, the dispute function, the board views, license_verified, lead_purchase_id.
supabase/functions/stripe-lead-checkoutNew. The reversed checkout.
supabase/functions/stripe-lead-releaseNew. The payout after the window.
supabase/functions/stripe-webhookBranches on lead purchases for completed, expired and refunded.
src/hooks/useMarketplace.tsLead hooks read the tables and functions; referrals stay sample.
src/pages/SellLead.tsxRewritten. Contact fields, price band, buyer cap, payout gate, real licence flag.
src/pages/LeadBoard.tsx, src/components/LeadSellingSheet.tsxSell a lead button, Buy and Selling tabs, the seller's sheet with Collect and Withdraw, the return from Stripe.
src/components/LeadUnlockDialog.tsxHands off to Stripe live, takes a dispute reason.
src/lib/marketplace.ts, src/lib/database.types.tsRow types and the adapter from the view to the board.
src/lib/admin-metrics.ts, src/pages/Admin.tsx, src/components/admin/AdminRecords.tsx, src/hooks/useAdmin.tsLead sales in revenue, the marketplace card, the queue and the records.
src/demo/demo-data.ts, demo-store.tsA board, contacts, two leads for sale and one bought, with every operation in memory.
src/components/AppSidebar.tsx"Buy Leads" is "Leads".