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.
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.
/sell is reachable without
typing a URL.lead_listings, one in lead_contacts.
If the contact fails to save, the listing is taken back down, because a lead with no contact
is a product nobody can be sold.stripe-lead-checkout function refuses a
seller with no payouts, reuses an abandoned session, and sends the buyer to Stripe. The
webhook marks the purchase paid, and the contact policy opens on that row.stripe-lead-release transfers their share, idempotent on the purchase id.
| Step | What happens | Where |
|---|---|---|
| Post | The 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 |
| Buy | A 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 |
| Paid | The 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 |
| Unlock | The 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 |
| Dispute | Inside 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 |
| Collect | After 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 |
| Refund | Issued 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.
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
license_verified column on profiles, and a lead_purchase_id column on the
ledger. It ends with verification queries.lead_vendor is added to profiles.account_type and signup.delisted yet. That is an admin action to add to the
command center.tsc --noEmit clean; npm run build clean.| File | What changed |
|---|---|
| supabase/lead-marketplace-migration.sql | New. lead_listings, lead_contacts, lead_purchases, the protect trigger, the dispute function, the board views, license_verified, lead_purchase_id. |
| supabase/functions/stripe-lead-checkout | New. The reversed checkout. |
| supabase/functions/stripe-lead-release | New. The payout after the window. |
| supabase/functions/stripe-webhook | Branches on lead purchases for completed, expired and refunded. |
| src/hooks/useMarketplace.ts | Lead hooks read the tables and functions; referrals stay sample. |
| src/pages/SellLead.tsx | Rewritten. Contact fields, price band, buyer cap, payout gate, real licence flag. |
| src/pages/LeadBoard.tsx, src/components/LeadSellingSheet.tsx | Sell a lead button, Buy and Selling tabs, the seller's sheet with Collect and Withdraw, the return from Stripe. |
| src/components/LeadUnlockDialog.tsx | Hands off to Stripe live, takes a dispute reason. |
| src/lib/marketplace.ts, src/lib/database.types.ts | Row 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.ts | Lead sales in revenue, the marketplace card, the queue and the records. |
| src/demo/demo-data.ts, demo-store.ts | A board, contacts, two leads for sale and one bought, with every operation in memory. |
| src/components/AppSidebar.tsx | "Buy Leads" is "Leads". |