# Customer onboarding runbook

Follow this every time a new customer pays on Stripe and submits the setup form. Target total time: under 30 minutes (Wave 2 flow) vs 2 hours (legacy flow).

## Which flow

- **Wave 2 DB-row flow** (default, all new customers): Claude generates a SQL insert for `public.customers`. You paste it in the Supabase SQL Editor. Customer is live at `/e/<slug>`. No HTML files created, no separate deploy needed.
- **Legacy HTML flow** (only for customers with non-standard product lineups): Claude generates a `estimator/<slug>/estimate.html` file. See [Legacy HTML flow](#legacy-html-flow) at the bottom.

Claude decides which flow based on the intake `Products` field. Trust it unless you want to force one explicitly.

---

## Wave 2 DB-row flow

### What Claude does for you

Paste the intake email into the chat. Claude:
1. Parses the labels → values.
2. Picks the Wave 2 DB-row flow (standard 5 keys: pine, cedar, chain, vinyl, alum).
3. Writes a filled seed SQL to `supabase/seed/customer-<slug>.sql`.
4. (If a logo is supplied as a local file) drops it at `estimator/<slug>/logo.png`.
5. `git add` + `git commit`, does **not** push.
6. Returns a TODO list with the manual steps below.

### Your manual steps

#### 1. Review the commit

```
git log -1 --stat
git diff HEAD~1
```

Expected files: `supabase/seed/customer-<slug>.sql` and optionally `estimator/<slug>/logo.png`. Nothing else should have moved.

#### 2. Apply the seed to Supabase

Open Supabase → SQL Editor → paste the contents of `supabase/seed/customer-<slug>.sql` → run.

You should see a single row inserted (or updated, if the slug already exists — the template uses `ON CONFLICT (slug) DO UPDATE`).

Confirm:
```sql
select id, slug, business_name, status, plan from public.customers where slug = '<slug>';
```

#### 3. Create the per-customer GHL workflow

In GHL, create a workflow triggered by a per-customer webhook that:
- Sends an SMS to `notify_phone` with the lead summary.
- Sends an email to `notify_email`.
- (Optional) Forwards the lead to JobNimbus via an HTTP action.

Paste the webhook URL into the seed SQL (`ghl_webhook_url`), re-run the seed in Supabase to update the row.

#### 4. Smoke test locally

```
npx http-server -p 8080 -c-1
# then open:
http://localhost:8080/e/<slug>
```

(Local dev does not hit Netlify's `/e/:slug` rewrite rule. For the pretty URL locally, use `http://localhost:8080/estimate.html?slug=<slug>` instead.)

Verify:
- Header shows the customer's company name.
- Brand color applied to header + buttons (if `brand_color` is a hex).
- Warranty bar shows the customer's 3 badges.
- Enter a test address → map loads (needs Maps key domain authorization).
- Draw a fence, pick type, generate estimate, submit lead form with a throwaway email.
- Your phone gets the SMS (GHL path); Supabase `public.estimates` has a new row (dual-write path); PDF downloads.

#### 5. Push to prod

```
git push
```

Wait for Netlify deploy. Live URL: `https://nextestimateagency.com/e/<slug>`.

#### 6. Add customer's domain to Maps key allowed referrers

In Google Cloud Console → APIs → Credentials → Maps key → Allowed referrers, add the customer's own site (if they'll embed the link).

#### 7. Hand off to the customer

Text the decision maker:

> Your estimator is live: `https://nextestimateagency.com/e/<slug>`
>
> Click around, pull up an address, draw a fence, submit a test lead with your own email. Let me know what you want tweaked.
>
> When you're ready, book the 30-min integration call and we'll get a button on your site: [calendar link]

#### 8. Integration call (30 min)

Walk through adding a call-to-action button on their main page and fence page that links to their estimator URL. Screen-share to confirm. Submit one live lead together to confirm the SMS and CRM fire.

### Rollback (Wave 2 flow)

If smoke test fails after applying the seed, roll back the DB row:

```sql
update public.customers set status = 'paused' where slug = '<slug>';
```

This makes `get_customer_config(slug)` return `NULL`, so `/e/<slug>` shows the "Estimator unavailable" page. Their ad traffic 404s cleanly rather than seeing a broken page.

To fix, edit the seed SQL, re-run it (the `ON CONFLICT ... DO UPDATE` path flips `status` back to `'trial'`), and retest.

If the commit itself was bad (wrong files), `git revert HEAD` + `git push` rolls back the repo side; the DB row stays in place.

### One-time Supabase Storage setup (Phase C, product photos)

Before the first customer uploads a fence-type photo on `customer-setup.html`, create the Storage bucket once:

1. Supabase UI → **Storage** → **New bucket**
2. Name: `product-images`
3. **Public: ON** (fence-card thumbnails resolve via public URL, no signed URLs needed)
4. Save

The Phase C migration (`20260424190000`) creates the RLS policies that limit anon uploads to the `pending/*` prefix. Without the bucket existing first, those policies bind to nothing.

### One-time Supabase Storage setup (lead PDFs)

Before the first lead PDF is generated by `submit-estimate.js`, create the bucket once:

1. Supabase UI → **Storage** → **New bucket**
2. Name: `lead-pdfs`
3. **Public: OFF** (leads contain PII; access is via signed URLs only)
4. Save

The PDF generator is best-effort: a missing or misconfigured bucket logs a warning and the lead flow proceeds without the archive (the email attachment still goes out because the PDF is held in memory and attached before upload). But you'll lose the historical archive until the bucket exists.

### Per-customer: claim uploaded product images (Phase C)

Customer-setup submissions with photos attached land in `public.product_images` with `customer_id = NULL` and `pending_slug = <slug>`. After you apply the customer seed SQL (step 2 above), link those rows to the new customer:

```sql
update public.product_images
set customer_id = (select id from public.customers where slug = '<slug>'),
    pending_slug = null,
    claimed_at = now()
where pending_slug = '<slug>' and customer_id is null;
```

If this returns `UPDATE 0`, the customer didn't upload any photos — nothing to do. Otherwise `UPDATE N` confirms N photos now belong to the customer. Periodically (monthly) sweep stale pending rows that never got claimed:

```sql
delete from public.product_images
where customer_id is null and pending_slug is not null
  and created_at < now() - interval '30 days';
```

(Storage files still need a manual sweep via the Supabase UI; no automation yet.)

### Per-customer: claim service-area polygon (Wave 3 Phase A)

If the customer drew a service-area polygon during setup, it lands in `public.service_areas` with `customer_id = NULL` and `pending_slug = <slug>`. After you apply the customer seed SQL, link it to the new customer:

```sql
update public.service_areas
set customer_id = (select id from public.customers where slug = '<slug>'),
    pending_slug = null,
    claimed_at = now()
where pending_slug = '<slug>' and customer_id is null;
```

`UPDATE 0` means the customer skipped the polygon (drawing was optional). `UPDATE 1` means the polygon is now wired to the customer. Visual sanity check:

```sql
select id, name, is_primary,
       ST_NPoints(polygon::geometry) as vertex_count,
       round((ST_Area(polygon) / 2589988.110336)::numeric, 1) as sq_miles
from public.service_areas
where customer_id = (select id from public.customers where slug = '<slug>');
```

If the GHL intake email shows `service_area_error` populated, the customer's polygon was rejected by `ST_IsValid` (usually self-intersecting). Either ask them to redraw via /my/updates, or skip — the system simply leaves their `in_service_area` flag NULL on incoming leads (Phase B treats NULL as "unknown, don't flag").

Periodic sweep of stale pending polygons (monthly, optional):

```sql
delete from public.service_areas
where customer_id is null and pending_slug is not null
  and created_at < now() - interval '30 days';
```

### One-time Supabase Auth setup (Phase D, customer login)

Before the first customer signs in at `/my/updates`, configure the redirect URLs in Supabase so magic-link emails point back to the right domain:

1. Supabase UI → **Authentication → URL Configuration**
2. **Site URL**: `https://nextestimateagency.com`
3. **Redirect URLs**: add every URL that should be allowed as a post-signin landing page. At minimum:
   - `https://nextestimateagency.com/my/updates`
   - `https://nextestimateagency.com/my-updates.html`
   - `http://localhost:8080/my/updates` (for local dev)
   - `http://localhost:8080/my-updates.html`

Magic-link is enabled by default on new Supabase projects; no extra steps. You can customize the email template under Authentication → Email Templates → "Magic Link" if you want the subject to say "Next Estimate" instead of the default.

### Per-customer: link the user to the customer (Phase D)

Unlike the per-customer claim for product photos (which runs automatically inside the seed SQL), the user-to-customer link is deferred until the customer first signs in, because `auth.users` rows don't exist until Supabase creates them on the first magic-link flow.

Flow:

1. Email the customer's decision maker: "Sign in at `https://nextestimateagency.com/my/updates` with your `<email>`".
2. They click the link, enter their email, click the magic link in their inbox, and land on `/my/updates` signed in. They see the "not linked" warning because `public.users` has no row for them yet.
3. You run this link SQL in Supabase SQL Editor:

   ```sql
   insert into public.users (id, customer_id, email, role)
   select au.id,
          (select id from public.customers where slug = '<slug>'),
          au.email,
          'owner'
     from auth.users au
    where lower(au.email) = lower('<decision-maker-email>')
   on conflict (id) do nothing;
   ```

   Confirm with: `select id, customer_id, email, role from public.users where email = lower('<decision-maker-email>');`

4. Text them: "You're linked, refresh the page." They see their dashboard.

If the customer used a different email than expected, update the query to match the email they actually signed in with. The `auth.users` table (readable from the SQL Editor) shows every signed-up email.

### One-time Netlify setup (Phase B3, lead routing)

The estimator submits leads to `/.netlify/functions/submit-estimate`, which fires the customer's GHL webhook and writes to Supabase using a service-role key. Before the first real lead flows through, set these env vars in Netlify UI → Site settings → Environment variables (scope: Builds + Functions):

- `SUPABASE_URL` — the public project URL, e.g. `https://jaokpzrwniofkthgkmrl.supabase.co`
- `SUPABASE_SERVICE_ROLE_KEY` — from Supabase → Project Settings → API → service_role. **Do not commit this, do not paste it in chat.** Rotate via Supabase UI if ever leaked.

After setting the env vars, trigger a manual redeploy (Netlify → Deploys → "Trigger deploy" → "Clear cache and deploy site") so the function picks them up. Adding env vars alone does not auto-redeploy.

Smoke test: on `/e/hicks-fencing` (or any live customer), submit a test lead with your throwaway email. Expected:
- The submit button shows success.
- Supabase `public.estimates` has a new row with `customer_id` matching the slug.
- Hicks's GHL webhook fires (check the customer's GHL inbox).
- Network tab shows one POST to `/.netlify/functions/submit-estimate` with `{ok: true, estimate_id: "...", ghl_ok: true}`.

If the function returns 500 with "missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY", env vars are not set. If 502 with `supabase_ok: false`, check function logs in Netlify → Functions → submit-estimate.

### Ongoing changes (Wave 2 flow)

Customer wants to tweak pricing after launch?

```sql
update public.customers
set pricing_json = pricing_json || '{"teardown_rate": 5.5}'::jsonb
where slug = '<slug>';
```

Or rewrite any field. No commit, no deploy — next page load picks up the change. Supabase free tier includes edit history on the row.

Subscription cancelled?

```sql
update public.customers set status = 'churned' where slug = '<slug>';
```

Their link shows "Estimator unavailable."

---

## Wave 3 self-serve trial flow (Phase D)

Customers can sign up themselves at `/start` without you in the loop. They pay through a Stripe Payment Link with a 7-day trial, the webhook creates their `customers` row + auth user, and Resend sends a welcome email with their `/e/<slug>` URL plus a one-click magic-link sign-in.

You only get involved if something breaks (the webhook didn't fire, the welcome email didn't land, etc.) or if the customer wants help during onboarding.

### One-time setup

These run once per Netlify+Supabase project and are not repeated per customer.

#### 1. Stripe Payment Link configuration

In Stripe Dashboard → Payment Links → your "Next Estimate" link → Edit:

- **Custom fields** → Add field → Label "Company name", Type Text, Required ON. The webhook uses this to derive the customer's slug. If the field name isn't exactly `company`, the webhook falls back to scanning custom fields for any with "company" in the key/label, so minor variations work.
- **After payment** → Show confirmation page → "Don't show confirmation page" → redirect URL: `https://nextestimateagency.com/trial-success`.
- **Subscription settings** → trial period 7 days (already set when you created the link).

#### 2. Stripe webhook endpoint

Stripe Dashboard → Developers → Webhooks → Add endpoint:

- URL: `https://nextestimateagency.com/.netlify/functions/stripe-webhook`
- Events to subscribe to:
  - `checkout.session.completed`
  - `checkout.session.expired` *(Phase B: funnel cart abandonment)*
  - `checkout.session.async_payment_failed` *(Phase B: declined-card abandonment)*
  - `customer.subscription.updated`
  - `customer.subscription.deleted`
  - `customer.subscription.trial_will_end`
- After save, click into the endpoint → "Signing secret" → Reveal → copy.

#### 3. Netlify env vars (in addition to the existing SUPABASE_* and GOOGLE_MAPS_*)

- `STRIPE_WEBHOOK_SECRET` — the signing secret you just copied (`whsec_...`). Scope: All scopes. The function uses this to verify the signature on each Stripe webhook delivery.
- `RESEND_API_KEY` — already set during Wave 3 vendor setup. The webhook + submit-estimate.js both use it.

`STRIPE_SECRET_KEY` is **not** required for this webhook. We deliberately route cancellations through `/my/updates` rather than a one-click Stripe billing portal link, so the webhook never calls the Stripe API directly. If a future feature needs it (e.g., a self-serve manage-subscription page), add it then.

After saving env vars, **Trigger deploy → Deploy project without cache** so the function bundle picks them up.

#### 3b. Meta CAPI: one-time setup

The Stripe webhook fires two server-side Conversions API events into Meta Events Manager:

- **`Lead`** when `customer.subscription.created` fires (trial signup, no money yet).
- **`Subscribe`** when the first `invoice.payment_succeeded` clears (trial-to-paid OR yearly upfront).

Both feed Jake's "paying customers" custom audience (Meta Ads Manager → Audiences → Create → Custom Audience → Website → "People who triggered Subscribe"). Match quality is high: hashed email + first name + country, plus raw `_fbp` / `_fbc` Pixel cookies threaded through Stripe's `client_reference_id`.

##### A. Apply the migration

Open Supabase → SQL Editor → paste `supabase/migrations/20260506120000_meta_capi_events.sql` → run. Confirm:

```sql
-- New columns on customers
select column_name from information_schema.columns
where table_schema='public' and table_name='customers' and column_name in ('fbp','fbc');

-- New idempotency table
select table_name from information_schema.tables
where table_schema='public' and table_name='capi_events_sent';

-- Service-role grants present
select grantee, privilege_type from information_schema.role_table_grants
where table_schema='public' and table_name='capi_events_sent';
```

Expect 2 rows, 1 row, and `service_role` with SELECT/INSERT/DELETE respectively.

##### B. Generate the CAPI access token

Meta Events Manager → Data Sources → pick the pixel `967360269010748` (the one already loaded on `index.html`) → Settings → Conversions API → "Generate Access Token". Copy the token.

##### C. Set Netlify env vars

Site settings → Environment variables (scope: Builds + Functions):

- `META_CAPI_ACCESS_TOKEN` — paste the token from step B.
- `META_TEST_EVENT_CODE` — temporarily set to a value from Events Manager → Test Events tab. This routes events to the Test Events tab so the production audience stays clean during verification. Unset later for go-live.
- `META_PIXEL_ID` — leave unset; defaults to `967360269010748`. Only override if you ever move the audience to a different ad account.

After saving env vars, **Deploys → Trigger deploy → Deploy project without cache** so the function bundle picks them up.

##### D. Confirm `NEM_ESTIMATOR_PRODUCT_IDS` covers the trial product

```
echo $NEM_ESTIMATOR_PRODUCT_IDS  # in Netlify env editor
```

The estimator's Stripe product ID(s) must be in this comma-separated list. Without it, `stripe-webhook.js` logs `[capi.Lead] NEM_ESTIMATOR_PRODUCT_IDS missing, skipping` and no events fire. To find the product ID: Stripe Dashboard → Products → "Next Estimate" → copy the `prod_...` ID.

##### E. Verify with synthetic webhook events

Stripe Dashboard → Developers → Webhooks → live endpoint → "Send test webhook":

1. **`customer.subscription.created`** → edit `items.data[0].price.product` to a real estimator product ID, set `customer` to a `cus_...` that already has a row in `public.customers`. Watch Netlify function logs for `[capi] ok Lead <sub_id> (test mode)`. Open Events Manager → Test Events. The Lead event should appear with match quality "Good" or higher within seconds.
2. **`invoice.payment_succeeded`** → for the same customer, with a matching product in `lines.data[0].price.product`. Confirm `[capi] ok Subscribe <invoice_id> (test mode)` in logs and a Subscribe event in Test Events with the correct `value` and `currency`.
3. **Idempotency check** → re-send the same `invoice.payment_succeeded` event. Logs should show no second `[capi] ok` line and Test Events should NOT receive a duplicate. Behind the scenes, the second `INSERT INTO capi_events_sent` returns 409 and the helper short-circuits.
4. **Filter check** → send a synthetic `customer.subscription.created` whose product is NOT in `NEM_ESTIMATOR_PRODUCT_IDS`. Logs should show `[capi.Lead] not an estimator product, skipping <sub_id>` and Meta should receive nothing.

##### F. Browser-side cri sanity check

Load `https://nextestimateagency.com/?utm_source=test&fbclid=ABCxxx` in Chrome with the Meta Pixel cookie present. Open DevTools → Elements → search for `buy.stripe.com`. The link's `client_reference_id` query param should look like:

```
nem|s=test|f=ABCxxx|b=<13-digit_ts>.<rand>|g=<13-digit_ts>
```

Total length under 200 chars. If `b=` and `g=` are missing, the Pixel cookies aren't being set on this domain — sanity-check `fbq('init', '967360269010748')` is present on the page (it is on `index.html`).

##### G. Go live

Once steps E and F pass, **unset `META_TEST_EVENT_CODE`** in Netlify and trigger a redeploy. From this point on, `Lead` and `Subscribe` events accrue in the production pixel. After 24-48 hours of real traffic, build the audience: Ads Manager → Audiences → Custom Audience → Website → "People who triggered Subscribe in the last 180 days".

##### Troubleshooting

- `[capi] META_CAPI_ACCESS_TOKEN missing, skipping Lead` — env var isn't set or the deploy hasn't picked it up. Trigger a fresh deploy without cache.
- `[capi] Lead <id> 400 ... "Invalid parameter"` — usually a malformed `event_time` or missing pixel ID. Re-check `META_PIXEL_ID` matches the ad account.
- `capi.Lead: customer not yet inserted` (in Stripe webhook delivery logs) — `customer.subscription.created` arrived before `checkout.session.completed` finished. Stripe will redeliver automatically; the next attempt typically succeeds. If you see this for the same event 5+ times, check that `handleCheckoutCompleted` is actually inserting the customer (Supabase Logs → API).
- Match quality stuck at "Poor" or "Fair" — usually means `_fbp`/`_fbc` are missing. Confirm step F. Email-only matching tops out around "Good".

#### 4. Test mode dry run

Before pointing real customers at `/start`, verify the round-trip in Stripe Test Mode:

1. Stripe Dashboard → top-right toggle to "Test mode."
2. Open your Payment Link in test mode (Stripe gives you a test-mode URL).
3. Sign up with a throwaway email + Stripe's test card `4242 4242 4242 4242`, any future expiry, any CVC.
4. Check Stripe → Webhooks → your endpoint → recent attempts. The `checkout.session.completed` should show "Succeeded."
5. In Supabase: `select id, slug, business_name, status, plan, stripe_customer_id from public.customers order by created_at desc limit 1;` — the new trial customer should appear with `status='trial'`, `plan='pro'`.
6. Check the throwaway email inbox for the welcome email. Click the magic link, confirm it lands you signed in at `/my/updates`.
7. Open `/e/<their-slug>` in a fresh browser, submit a test fence lead. Check that an email arrives at the throwaway inbox with the lead details (Resend fallback fired because the trial customer has no GHL webhook).
8. Clean up: in Stripe test mode, cancel the subscription or delete the customer. In Supabase, `delete from public.users where customer_id = (select id from public.customers where slug = '<test-slug>'); delete from public.customers where slug = '<test-slug>';` and the corresponding `auth.users` row from the Supabase Auth UI.

After successful test, flip Stripe back to Live mode and the same flow runs against real cards.

### When a customer signs up

You don't have to do anything. The webhook handles it end-to-end.

You **may** want to verify periodically:

```sql
select id, slug, business_name, status, stripe_customer_id, created_at
from public.customers
where plan = 'pro' and status in ('trial','active')
order by created_at desc;
```

If a recent signup is missing a `stripe_customer_id` or has `status` other than `trial`, something went wrong with the webhook. Check Stripe Dashboard → Webhooks → recent attempts for failed deliveries.

### When a trial converts (after 7 days)

Stripe automatically charges the card and the subscription transitions from `trialing` to `active`. The `customer.subscription.updated` webhook fires, and our handler flips `customers.status` from `trial` to `active`. No manual action needed.

If the card fails, status flips to `paused` (mapped from Stripe's `past_due`). The `/e/<slug>` page goes dark per the existing `get_customer_config` logic. Customer can recover by updating their card via the Stripe billing portal — link is in the trial-ending email.

### When a trial customer cancels

- Cancel **before** trial ends (no charge): subscription is canceled, status → `churned`, `/e/<slug>` goes dark.
- Cancel **during the active subscription**: Stripe will downgrade at period end. status stays `active` until period end, then → `churned`.

You don't have to do anything either way. Wave 4's nightly Stripe reconciliation job (when it lands) is the safety net for any drift.

### Lead routing during trial

Trial customers don't have a `ghl_webhook_url`. submit-estimate.js detects this and sends the lead notification via Resend to `branding_json.notify_email` instead. Customer can add a GHL webhook later via `/my/updates` to switch to GHL routing. Until then, leads land in their inbox.

### Rollback / manual customer creation

If the webhook fails for some reason and you need to manually create a trial customer, use the Wave 2 DB-row flow with the trial seed. Set `status='trial'` and `stripe_customer_id` to the customer's `cus_...` ID from Stripe Dashboard → Customers. The customer can still receive leads (Resend fallback) until you wire up GHL.

---

## Legacy HTML flow

Use this only when the customer has:
- Non-standard product keys (`pine_metal`, `chainlink4`, `vinyl8`, etc.)
- A disabled product (`key` with `lo:0, hi:0` — e.g. Horner who doesn't offer pine)
- More or fewer than 5 entries in `Products`

These customers can't use the runtime template yet. A future Wave 2 template change will fix this; until then, they get a per-customer HTML file.

### Steps

1. **Claude generates** `estimator/<slug>/estimate.html` by duplicating the root, swapping the config block, flipping `IS_DEMO` to `false`, replacing the base64 logo `src`, and hand-editing the `PRODUCTS` / `PRICES` / `LABELS` structures to match the customer's shape.
2. **You review** the diff, then `git push`.
3. **Smoke test** at `http://localhost:8080/estimator/<slug>/estimate.html` and prod at `https://nextestimateagency.com/estimator/<slug>/estimate.html`.
4. **Hand off** with that URL.

No DB row, no `/e/<slug>` redirect. Leads still fire through GHL and CRM normally, but do not dual-write to Supabase (no `CUSTOMER_ID` in their config).

### Rollback (legacy flow)

```
git revert HEAD
git push
```

Customer's old link (or no link) stays unbroken.

### Migrating a legacy customer to Wave 2

Will be unblocked by the Wave 2 template change (scheduled, not done yet). When it lands, the migration path is:
1. Add a DB row with `branding_json` + `pricing_json` built from their current HTML.
2. Add a 301 redirect in `netlify.toml` from `/estimator/<slug>/estimate.html` to `/e/<slug>`.
3. Delete the `estimator/<slug>/estimate.html` file after verifying the redirect.

See the existing 301 redirects for `hicks-fencing`, `kingdom`, and `jakes-fence` in `netlify.toml` as a reference (those three are already on the runtime path).

## Phase B: /funnel cart abandonment webhook

The /funnel quiz funnel writes one `quiz_funnel_leads` row per Q8 submit (via `submit-funnel-lead.js`). Each Stripe payment link click carries `client_reference_id = <lead UUID>`. The existing `stripe-webhook.js` was extended to flip that lead row to `paid` or `abandoned` based on Stripe events; the existing /lp/v3 attribution flow is untouched (it uses `nem|...` reference IDs, which the new code returns null on).

### One-time setup

**1. Apply the schema migration.** Paste `supabase/migrations/20260527150000_funnel_lead_payment_status.sql` into the Supabase SQL Editor. Adds `status` (CHECK constraint: `lead | paid | abandoned`), `paid_at`, `paid_tier`, `stripe_session_id` columns on `public.quiz_funnel_leads`. Idempotent.

**2. Subscribe the new events.** In Stripe Dashboard → Developers → Webhooks → your existing endpoint (the one set up in the Wave 3 trial section above), add:
- `checkout.session.expired` *(fires 24h after a session is created without a payment)*
- `checkout.session.async_payment_failed` *(card declined post-confirm)*

No new endpoint, no new signing secret — the same `STRIPE_WEBHOOK_SECRET` env var verifies both /lp/v3 and /funnel deliveries.

**3. Enable "Pass client reference ID" on each funnel payment link.** Stripe Dashboard → Payment Links → each of the four funnel links (`eVqeV5cso8Se12r90ScAo0p`, `00w4gr784fgC8uT5OGcAo0o`, `cNi8wHdws9WifXl3GycAo0r`, `5kQ3cn2RO6K6fXldh8cAo0s`) → Advanced settings → toggle on. Without this, Stripe drops the UUID and the webhook can't correlate back to the funnel row.

**4. Wire the Make scenario + GHL contact-create workflow.** As of the Make migration (2026-05), the funnel fires two webhooks: the AI SMS sales-agent state lives in a single Make scenario, and a stripped-down GHL workflow still upserts the contact so an SMS-able row exists.

Two env vars:
- `FUNNEL_MAKE_WEBHOOK` — single URL for all three funnel events (`quiz_submitted`, `cart_abandoned`, `purchase_completed`). The Make scenario routes internally on `event_type`, re-reads rich row data from `quiz_funnel_leads` via Supabase RPC, and owns state + cadence + SMS messaging.
- `FUNNEL_GHL_WEBHOOK_LEAD` — fires only on Q8 submit (alongside the Make fire). The GHL Funnel-Lead workflow it triggers is trimmed to a single Create-Contact-if-not-exists action; the original Update-Contact step and the 1h/24h/72h cart-abandoner cadence moved to Make. The legacy `FUNNEL_GHL_WEBHOOK_PAID` and `FUNNEL_GHL_WEBHOOK_ABANDONED` env vars are gone.

Make payload (slim — all three event types share the shape):

```json
{
  "event_type": "quiz_submitted" | "cart_abandoned" | "purchase_completed",
  "contact_id": "<uuid>",
  "event_payload": {
    "stripe_session_id": "cs_...",        // PAID + ABANDONED only
    "attempted_tier": "tier2"             // ABANDONED only
  }
}
```

GHL Lead payload (unchanged from PR #85 — flat for direct custom-field mapping during contact upsert):

```json
{
  "event": "funnel_q8_submitted",
  "event_type": "lead",
  "contact_id": "<uuid>",
  "email": "...", "phone": "+15551234567", "first_name": "...", "website": "...",
  "recommended_tier": "tier1_monthly" | ...,
  "prescription_url": "https://nextestimateagency.com/funnel?r=<contact_id>",
  "q1": "yep" | "nope",
  "q2": "under_5" | "5_to_15" | "15_plus",
  "q3": "wasted_drives" | "slow_follow_up" | "quoting_bottleneck" | "lousy_website",
  "q4_installs": 12,
  "q5": "nobody" | "me" | "friend_family" | "agency",
  "q6": "drive_out" | "basics_first" | "quote_remote" | "depends",
  "q7": "no_website" | "needs_work" | "redoing" | "fine",
  "variant": "funnel-v1",
  "utm_source": "...", "utm_medium": "...", "utm_campaign": "...",
  "utm_content": "...", "utm_term": "...", "page_referrer": "...",
  "submitted_at": "<iso>"
}
```

Workflow shapes:
- **GHL Funnel-Lead** (still active): on inbound, upsert contact by email+phone, write the 17 custom fields from the payload, then STOP. No cadence, no further actions — Make takes over from there.
- **GHL Funnel-Bought** and **GHL Funnel-Abandoned**: should be deactivated (don't delete; rollback safety). They no longer receive any traffic.
- **GHL inbound-SMS forwarder** (new, owned by the Make session): when a contact replies to a Sarah SMS, POST `{event_type: "inbound_reply", contact_id, message_body}` to `FUNNEL_MAKE_WEBHOOK`. `contact_id` is resolved by phone in GHL before the POST. Not funnel-side code.

Full payload contract + field semantics: `FUNNEL-WEBHOOK-CONTRACT.md` at repo root.

### Testing with Stripe CLI

```bash
stripe listen --forward-to https://nextestimateagency.com/.netlify/functions/stripe-webhook

# In a separate terminal — substitute a real UUID from quiz_funnel_leads:
stripe trigger checkout.session.completed \
  --override 'checkout_session:client_reference_id=<lead-uuid>'
stripe trigger checkout.session.expired \
  --override 'checkout_session:client_reference_id=<lead-uuid>'
stripe trigger checkout.session.async_payment_failed \
  --override 'checkout_session:client_reference_id=<lead-uuid>'
```

After each trigger:
- Supabase: `select status, paid_at, abandoned_at, paid_tier, stripe_session_id from public.quiz_funnel_leads where contact_id = '<lead-uuid>';`
- Make scenario: check the scenario's "History" tab for an execution with the matching `event_type`.
- GHL contact (lead trigger only): confirm the contact exists with the right custom fields populated.

Defensive behaviors worth knowing:
- `async_payment_failed` is treated identically to `expired` (same `status='abandoned'`). `stripe_session_id` lets you tell them apart in Stripe.
- If `expired` fires after `completed` for the same session (rare but documented), the handler skips the update because `status='paid'` already.
- /lp/v3 sessions (whose `client_reference_id` is the `nem|...` attribution string) are detected by the non-UUID format and silently ignored by all funnel branches — no risk of accidentally writing to `quiz_funnel_leads`.
