# Appendix A — Reference Worker (complete, ready to adapt)

[← Back to README](README.md) · [← 04 The worker](04-the-worker.md) · [03 Pricing engine](03-pricing-engine.md) · [06 Go live & monitor →](06-go-live-and-monitor.md)

---

This is the complete, runnable worker that powers the Facebook-lead → auto-quote-SMS
engine. It is derived from NeatNow's real production worker, **stripped of every secret
and trimmed to the Facebook-lead path only** so you (or your AI assistant) can adapt it
for any cleaning business.

> The real NeatNow worker also has a **website-form path** (a `/lead` endpoint that quotes
> from bedroom counts and classifies neighborhoods into pricing tiers). That path is **out
> of scope** for this guide — you have no website — so it is omitted here. Everything below
> is the Facebook-lead path, intact and faithful.

### How to read this appendix

Two kinds of code live here, and the difference is the whole point:

- 🔒 **ENGINE — don't touch.** The plumbing: routing, signature verification, lead
  fetching, the fail-loud safety net, the SMS-send helper. It is business-agnostic. If you
  change it, you risk breaking the safety guarantees.
- 🔧 **TENANT CONFIG — edit these.** Your prices, your SMS wording, your phone numbers, your
  IDs. There are only **three** files/spots you normally touch: `pricing.ts`,
  `fb-sms-template.ts`, and the placeholder values in `wrangler.toml`.

### File tree

```
your-worker/
├── wrangler.toml          🔧 TENANT CONFIG — names, vars, placeholder values
├── package.json            🔒 engine
└── src/
    ├── index.ts            🔒 ENGINE — router, fail-loud handler, SMS helper
    ├── facebook.ts         🔒 ENGINE — signature verify, lead fetch, field parse
    ├── pricing.ts          🔧 TENANT CONFIG — your square-footage prices
    └── fb-sms-template.ts  🔧 TENANT CONFIG — your SMS wording + owner notification
```

---

## `package.json` 🔒

```json
{
  "name": "your-worker",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy"
  },
  "devDependencies": {
    "wrangler": "^4.0.0",
    "typescript": "^5.0.0"
  }
}
```

---

## `wrangler.toml` 🔧 TENANT CONFIG

Edit `name` and every value under `[vars]`. **Do not put secrets in this file** — they are
set separately with `wrangler secret put` (see the comment block).

> **Config format:** Cloudflare now recommends **`wrangler.jsonc`** for new projects (some
> newer features are JSON-config only); the `wrangler.toml` shown below still works. Wrangler
> v4 also dropped Node 16 support. See the
> [configuration docs](https://developers.cloudflare.com/workers/wrangler/configuration/).

```toml
name = "your-worker"            # 🔧 becomes part of your public URL
main = "src/index.ts"
compatibility_date = "2024-09-23"

[vars]
# 🔧 Your business SMS line (the "from" number), E.164 format. Example only — use your own.
OPENPHONE_FROM_NUMBER = "+15550000000"
# 🔧 Backup endpoint that receives a copy of every lead. Example only — use your own.
FORMSPREE_ENDPOINT = "https://formspree.io/f/XXXXXXXX"
# Websites allowed to call the worker (CORS). Only a website form path uses this; this
# FB-only worker never reads it, so you can leave it or delete it.
ALLOWED_ORIGINS = "https://example.com"
# Graph API version to call. Use a current version (v25.0 was current as of June 2026) and
# check the changelog — versions deprecate on a ~2-year cycle.
# https://developers.facebook.com/docs/graph-api/changelog
FB_GRAPH_API_VERSION = "v25.0"
# 🔧 YOUR mobile number — gets the "new lead" detail SMS + the fail-loud alert. Example only.
FB_OWNER_NOTIFY_PHONE = "+15551112222"

# ──────────────────────────────────────────────────────────────────────────
# SECRETS — never written in this file. Set each with:
#   printf '%s' "<VALUE>" | npx wrangler secret put <NAME>
# (applies immediately — secret put creates and deploys a new Worker version right away;
#  no separate `wrangler deploy` needed)
#
#   OPENPHONE_API_KEY      — your SMS provider API key
#   FB_VERIFY_TOKEN        — a random string YOU invent, used for the webhook handshake
#   FB_APP_SECRET          — your Facebook App Secret, used for HMAC signature verification
#   FB_PAGE_ACCESS_TOKEN   — NEVER-EXPIRING SYSTEM USER token with leads_retrieval,
#                            pages_read_engagement, pages_manage_ads, pages_show_list,
#                            ads_management (and the Page assigned with the LEADS task enabled)
# ──────────────────────────────────────────────────────────────────────────
```

---

## `src/index.ts` 🔒 ENGINE

Router + Facebook-lead handler (with the fail-loud safety net) + the SMS-send helper.
**Do not edit this file** beyond reading it — your changes belong in `pricing.ts` and
`fb-sms-template.ts`.

```typescript
// FB Lead Intake Worker — Facebook Lead Ads → auto-quote SMS.
// ENGINE: do not edit. Tenant config lives in pricing.ts and fb-sms-template.ts.

import { verifyWebhookSignature, fetchLeadData, parseLeadFields, type ParsedFbLead } from './facebook';
import { fbLeadSms, fbLeadOwnerNotification } from './fb-sms-template';

interface Env {
  // Secrets
  OPENPHONE_API_KEY: string;
  FB_VERIFY_TOKEN: string;
  FB_APP_SECRET: string;
  FB_PAGE_ACCESS_TOKEN: string;
  // Vars
  OPENPHONE_FROM_NUMBER: string;
  FORMSPREE_ENDPOINT: string;
  FB_GRAPH_API_VERSION: string;
  FB_OWNER_NOTIFY_PHONE?: string;
}

// Normalize a phone number to E.164 (+1XXXXXXXXXX for North America).
// Returns null if it can't be made valid — caller must handle null.
function normalizePhone(raw: string): string | null {
  if (!raw?.trim()) return null;
  const cleaned = raw.trim();
  const hasPlus = cleaned.startsWith('+');
  const digits = cleaned.replace(/\D/g, '');
  if (digits.length === 10) return `+1${digits}`;
  if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`;
  if (hasPlus && digits.length === 11) return `+${digits}`;
  return null;
}

// ── SMS send helper ─────────────────────────────────────────────────────────
// 🔧 THIS is the one function to swap if you use a different SMS provider.
// Change the URL, auth header, body shape, and success status to match your provider.
// Quo (formerly OpenPhone) specifics: raw key (NO "Bearer" prefix); success = HTTP 202 (not 200).
// Docs: https://www.quo.com/docs/api-reference/messages/send-a-text-message
async function sendOpenPhoneSms(
  env: Env,
  to: string,
  content: string
): Promise<{ success: boolean; error?: string }> {
  try {
    // api.quo.com is canonical; the legacy api.openphone.com host may still resolve.
    const resp = await fetch('https://api.quo.com/v1/messages', {
      method: 'POST',
      headers: {
        'Authorization': env.OPENPHONE_API_KEY, // raw key, no "Bearer"
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ content, from: env.OPENPHONE_FROM_NUMBER, to: [to] }),
    });
    if (resp.status === 202) return { success: true };
    const body = await resp.text();
    return { success: false, error: `OpenPhone ${resp.status}: ${body.slice(0, 200)}` };
  } catch (err) {
    return { success: false, error: `OpenPhone fetch error: ${(err as Error).message}` };
  }
}

// Backup: post a copy of the lead to a backup endpoint (Formspree by default).
// Fire-and-forget; failure here must never block the main pipeline.
async function forwardFbLeadToFormspree(
  env: Env,
  lead: ParsedFbLead,
  meta: { ad_id?: string; campaign_id?: string; form_id?: string }
): Promise<void> {
  try {
    const resp = await fetch(env.FORMSPREE_ENDPOINT, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
      body: JSON.stringify({
        // Formspree's current special field is `subject` (legacy name was `_subject`).
        subject: `[FB LEAD] ${lead.fullName || lead.firstName || 'New Lead'}`,
        source: 'Facebook Lead Ad',
        name: lead.fullName,
        firstName: lead.firstName,
        lastName: lead.lastName,
        phone: lead.phone,
        email: lead.email,
        streetAddress: lead.streetAddress,
        serviceType: lead.serviceType,
        timing: lead.timing,
        squareFootage: lead.squareFootage,
        bathrooms: lead.bathrooms,
        rawFields: JSON.stringify(lead.rawFields),
        adId: meta.ad_id || '',
        campaignId: meta.campaign_id || '',
        formId: meta.form_id || '',
      }),
    });
    const respBody = await resp.text();
    console.log(`FB Formspree response: ${resp.status} ${respBody.slice(0, 200)}`);
  } catch (err) {
    console.error(`FB Formspree forwarding failed: ${(err as Error).message}`);
  }
}

// GET /facebook/webhook — Meta's verification handshake.
// Meta sends ?hub.mode=subscribe&hub.verify_token=X&hub.challenge=Y
// Echo hub.challenge back ONLY if hub.verify_token matches our secret.
function handleFbVerification(request: Request, env: Env): Response {
  const url = new URL(request.url);
  const mode = url.searchParams.get('hub.mode');
  const token = url.searchParams.get('hub.verify_token');
  const challenge = url.searchParams.get('hub.challenge');

  if (mode === 'subscribe' && token === env.FB_VERIFY_TOKEN && challenge) {
    console.log('FB webhook verification: success');
    return new Response(challenge, { status: 200 });
  }
  console.error(`FB webhook verification failed. mode=${mode} token-match=${token === env.FB_VERIFY_TOKEN}`);
  return new Response('Forbidden', { status: 403 });
}

// POST /facebook/webhook — incoming lead notification.
// Payload: { object:"page", entry:[{ changes:[{ field:"leadgen",
//            value:{ leadgen_id, page_id, form_id, ad_id, created_time } }] }] }
async function handleFbLead(request: Request, env: Env): Promise<Response> {
  // 1) Read raw body and verify the signature (proves it's really Meta).
  const rawBody = await request.text();
  const signature = request.headers.get('x-hub-signature-256');
  if (env.FB_APP_SECRET) {
    const valid = await verifyWebhookSignature(rawBody, signature, env.FB_APP_SECRET);
    if (!valid) {
      console.error('FB webhook: invalid signature');
      return new Response('Invalid signature', { status: 401 });
    }
  }

  let payload: any;
  try {
    payload = JSON.parse(rawBody);
  } catch {
    return new Response('Invalid JSON', { status: 400 });
  }
  if (payload.object !== 'page') {
    console.log(`FB webhook: ignoring object=${payload.object}`);
    return new Response('OK', { status: 200 });
  }

  for (const entry of (payload.entry || [])) {
    for (const change of (entry.changes || [])) {
      if (change.field !== 'leadgen') continue;
      const value = change.value || {};
      const leadgenId = value.leadgen_id;
      if (!leadgenId) {
        console.error('FB webhook: missing leadgen_id', JSON.stringify(value));
        continue;
      }
      console.log(`FB webhook: processing leadgen_id=${leadgenId}`);

      // 2) Fetch the actual lead answers via Graph API (needs FB_PAGE_ACCESS_TOKEN).
      const leadData = await fetchLeadData(
        leadgenId,
        env.FB_PAGE_ACCESS_TOKEN,
        env.FB_GRAPH_API_VERSION || 'v25.0'
      );

      // ── FAIL LOUD ──────────────────────────────────────────────────────────
      // A fetch failure almost always means FB_PAGE_ACCESS_TOKEN is invalid
      // (e.g. owner changed their Facebook password -> OAuth code 190 / subcode 460).
      // NEVER drop the lead silently — that silence cost ~2 days of leads once.
      if (!leadData) {
        console.error(`FB webhook: failed to fetch lead ${leadgenId}`);
        // (a) Alert the owner by SMS so a dead token is caught in minutes, not days.
        const alertPhone = env.FB_OWNER_NOTIFY_PHONE ? normalizePhone(env.FB_OWNER_NOTIFY_PHONE) : null;
        if (alertPhone) {
          const alert = `⚠️ Leads alert: a Facebook lead arrived but could NOT be fetched (leadgen_id ${leadgenId}). The page token is likely invalid (often after a Facebook password change). Regenerate FB_PAGE_ACCESS_TOKEN. The lead is recoverable from Meta Leads Center.`;
          const a = await sendOpenPhoneSms(env, alertPhone, alert);
          if (!a.success) console.error(`FB fetch-failure owner alert failed: ${a.error}`);
        }
        // (b) Leave an out-of-band trace so the lead is never lost even on fetch failure.
        try {
          await fetch(env.FORMSPREE_ENDPOINT, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
            body: JSON.stringify({
              subject: `[FB LEAD - FETCH FAILED] leadgen_id ${leadgenId}`,
              source: 'Facebook Lead Ad (fetch failed)',
              leadgenId,
              adId: value.ad_id || '',
              formId: value.form_id || '',
              note: 'Lead fetch failed — check/regenerate FB_PAGE_ACCESS_TOKEN. Retrievable from Meta Leads Center.',
            }),
          });
        } catch (err) {
          console.error(`FB fetch-failure Formspree trace failed: ${(err as Error).message}`);
        }
        continue;
      }

      // 3) Parse the answers.
      const lead = parseLeadFields(leadData);
      console.log(`FB lead parsed: ${lead.firstName} ${lead.email} ${lead.phone}`);

      // 4) Text the LEAD their quote.
      const normalizedPhone = normalizePhone(lead.phone);
      if (normalizedPhone) {
        const smsResult = await sendOpenPhoneSms(env, normalizedPhone, fbLeadSms(lead));
        if (!smsResult.success) console.error(`FB lead SMS failed: ${smsResult.error}`);
      } else {
        console.error(`FB lead: invalid phone "${lead.phone}"`);
      }

      // 5) Text YOU (the owner) the full details.
      const ownerPhone = env.FB_OWNER_NOTIFY_PHONE ? normalizePhone(env.FB_OWNER_NOTIFY_PHONE) : null;
      if (ownerPhone) {
        const notify = fbLeadOwnerNotification(lead, { ad_id: value.ad_id, form_id: value.form_id });
        const r = await sendOpenPhoneSms(env, ownerPhone, notify);
        if (!r.success) console.error(`FB lead owner notify failed: ${r.error}`);
        else console.log(`FB lead owner notify sent to ${ownerPhone}`);
      }

      // 6) Backup copy.
      await forwardFbLeadToFormspree(env, lead, {
        ad_id: value.ad_id, campaign_id: value.campaign_id, form_id: value.form_id,
      });
    }
  }

  // Always 200 so Meta doesn't retry.
  return new Response('OK', { status: 200 });
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // Facebook Lead Ads webhook
    if (url.pathname === '/facebook/webhook' || url.pathname === '/facebook/webhook/') {
      if (request.method === 'GET') return handleFbVerification(request, env);
      if (request.method === 'POST') return handleFbLead(request, env);
    }

    // Health check
    if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '/health')) {
      return new Response(JSON.stringify({ status: 'ok', service: 'fb-lead-worker' }), {
        status: 200, headers: { 'Content-Type': 'application/json' },
      });
    }

    return new Response('Not Found', { status: 404 });
  },
};
```

> NOTE: the real NeatNow worker also routes a `POST /lead` website-form endpoint here. It is
> omitted on purpose (out of scope). Everything you need for the Facebook path is above.

---

## `src/facebook.ts` 🔒 ENGINE

Signature verification, Graph API lead fetch, and field parsing. Business-agnostic — leave
it alone. Worth understanding `parseLeadFields`, though: it matches your lead-form questions
by keyword, so it survives small wording changes in your form.

```typescript
// Facebook Lead Ads webhook helpers — ENGINE, do not edit.
// HMAC signature verification, Graph API lead fetching, field parsing.

export interface FbLeadField { name: string; values: string[]; }

export interface FbLeadData {
  id: string;
  created_time: string;
  field_data: FbLeadField[];
  ad_id?: string;
  adset_id?: string;
  campaign_id?: string;
  form_id?: string;
}

export interface ParsedFbLead {
  firstName: string;
  lastName: string;
  fullName: string;
  email: string;
  phone: string;
  streetAddress: string;
  serviceType: string;
  timing: string;
  squareFootage: string;
  bathrooms: string;
  rawFields: Record<string, string>; // catchall for any custom answers
}

// Verify the X-Hub-Signature-256 header. Meta signs the raw body with HMAC-SHA256
// using your App Secret. Constant-time comparison to avoid timing attacks.
export async function verifyWebhookSignature(
  rawBody: string,
  signatureHeader: string | null,
  appSecret: string
): Promise<boolean> {
  if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false;
  const expectedHex = signatureHeader.slice('sha256='.length);

  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw', encoder.encode(appSecret),
    { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
  );
  const sigBuffer = await crypto.subtle.sign('HMAC', key, encoder.encode(rawBody));
  const computedHex = Array.from(new Uint8Array(sigBuffer))
    .map(b => b.toString(16).padStart(2, '0')).join('');

  if (computedHex.length !== expectedHex.length) return false;
  let diff = 0;
  for (let i = 0; i < computedHex.length; i++) {
    diff |= computedHex.charCodeAt(i) ^ expectedHex.charCodeAt(i);
  }
  return diff === 0;
}

// Fetch lead data from the Graph API using the leadgen_id.
// Requires a token (use a System User token) with leads_retrieval permission.
// Returns null on ANY failure — the caller's fail-loud path handles null.
export async function fetchLeadData(
  leadgenId: string,
  accessToken: string,
  apiVersion: string
): Promise<FbLeadData | null> {
  const url = `https://graph.facebook.com/${apiVersion}/${leadgenId}?access_token=${encodeURIComponent(accessToken)}`;
  try {
    const resp = await fetch(url, { method: 'GET' });
    if (!resp.ok) {
      const body = await resp.text();
      console.error(`Graph API error ${resp.status}: ${body.slice(0, 300)}`);
      return null;
    }
    return await resp.json() as FbLeadData;
  } catch (err) {
    console.error(`Graph API fetch error: ${(err as Error).message}`);
    return null;
  }
}

// Match a field by fuzzy keyword (lowercased, alphanumerics only). Lets the parser
// tolerate different question wording across forms.
function getField(fields: FbLeadField[], ...candidates: string[]): string {
  const normalized = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
  for (const candidate of candidates) {
    const target = normalized(candidate);
    const match = fields.find(f => normalized(f.name).includes(target));
    if (match && match.values?.length) return match.values[0];
  }
  return '';
}

export function parseLeadFields(lead: FbLeadData): ParsedFbLead {
  const fields = lead.field_data || [];

  const firstName = getField(fields, 'first_name', 'firstname');
  const lastName = getField(fields, 'last_name', 'lastname');
  const fullName = [firstName, lastName].filter(Boolean).join(' ').trim()
    || getField(fields, 'full_name', 'name');
  const email = getField(fields, 'email');
  const phone = getField(fields, 'phone_number', 'phone');
  const streetAddress = getField(fields, 'street_address', 'address');

  // Custom questions — matched by keywords in the question label.
  // 🔧 If you name your form questions very differently, add keywords here.
  const serviceType = getField(fields, 'service', 'type_of_service', 'what_type_of_service');
  const timing = getField(fields, 'how_soon', 'timing', 'when');
  const squareFootage = getField(fields, 'square_footage', 'sqft', 'size', 'approximate_square');
  const bathrooms = getField(fields, 'bathroom', 'how_many_bathrooms');

  const rawFields: Record<string, string> = {};
  for (const f of fields) rawFields[f.name] = (f.values || []).join(', ');

  return {
    firstName: firstName || fullName.split(' ')[0] || '',
    lastName, fullName, email, phone, streetAddress,
    serviceType, timing, squareFootage, bathrooms, rawFields,
  };
}
```

---

## `src/pricing.ts` 🔧 TENANT CONFIG — edit your prices here

This is the heart of *your* business logic. The example prices the quote from a
**square-footage bracket + bathroom count + service type**. **Replace every number in the
three square-footage tables below with your own** (these are derived from NeatNow's pricing
playbook — see [03](03-pricing-engine.md) for how the brackets were chosen).

> **Note on First-Time Clean:** `fbServiceTypeFromLabel` routes an answer containing `first`
> or `deep` → `first-time` out of the box, so the template works if you sell one. NeatNow's
> *live* form simply does not offer a first-time option (its choices are one-time, bi-weekly,
> and move-in/out), so in practice the branch never fires and the live form quotes Standard
> and Move In/Out only — that is the behavior described in [02](02-lead-form.md) and
> [03](03-pricing-engine.md). To quote First-Time from a Facebook lead, add a form option
> whose label contains "first" or "deep"; no code change needed. If you do not sell a
> first-time/deep clean, just leave the FTC table at zeros (or delete it and the
> `first-time` branch) and the path is inert.

```typescript
// Square-footage pricing for Facebook leads. 🔧 TENANT CONFIG — edit the tables.

export type ServiceType = 'standard' | 'first-time' | 'move-in-out';
export type Frequency = 'weekly' | 'biweekly' | 'monthly' | 'one-time';

const BATHROOM_ADDON = 25; // 🔧 per extra bathroom beyond the first
const GST_RATE = 0.05;     // 🔧 your tax rate. NOTE: not used on the FB path (FB quotes are shown pre-tax with a "(+ GST)" note, see formatFbPriceForSms); kept only if you add a tax-inclusive path.

// 🔧 Recurring discounts on the STANDARD service only.
const FREQUENCY_DISCOUNT: Record<Frequency, number> = {
  'weekly': 0.20, 'biweekly': 0.15, 'monthly': 0.10, 'one-time': 0,
};

export type SqftBracket =
  | '1700-1999' | '2000-2249' | '2250-2499'
  | '2500-2749' | '2750-2999' | 'custom';

// 🔧 Standard Clean base price per bracket (1 bathroom included).
const SQFT_STANDARD_BASE: Record<SqftBracket, number> = {
  '1700-1999': 225, '2000-2249': 235, '2250-2499': 252,
  '2500-2749': 270, '2750-2999': 294, 'custom': 0,
};
// 🔧 First-Time / deep clean EXTRA, added on top of Standard base.
const SQFT_FTC_EXTRA: Record<SqftBracket, number> = {
  '1700-1999': 175, '2000-2249': 175, '2250-2499': 175,
  '2500-2749': 225, '2750-2999': 225, 'custom': 0,
};
// 🔧 Move In/Out EXTRA, added on top of Standard base.
const SQFT_MIO_EXTRA: Record<SqftBracket, number> = {
  '1700-1999': 275, '2000-2249': 300, '2250-2499': 300,
  '2500-2749': 325, '2750-2999': 325, 'custom': 0,
};

// Map a sqft string from the FB form to a bracket. 3000+ → custom quote.
export function sqftToBracket(sqftLabel: string): SqftBracket {
  if (!sqftLabel) return 'custom';
  const lower = sqftLabel.toLowerCase().replace(/\s+/g, '').replace(/,/g, '');
  if (lower.includes('3000+') || lower.startsWith('3')) return 'custom';
  const match = lower.match(/(\d{3,5})/);
  if (!match) return 'custom';
  const num = parseInt(match[1], 10);
  if (num >= 3000) return 'custom';
  if (num >= 2750) return '2750-2999';
  if (num >= 2500) return '2500-2749';
  if (num >= 2250) return '2250-2499';
  if (num >= 2000) return '2000-2249';
  if (num >= 1700) return '1700-1999';
  return '1700-1999'; // below 1700 falls back to the smallest bracket
}

export function parseBathrooms(bathLabel: string): number {
  if (!bathLabel) return 1;
  const match = bathLabel.match(/(\d+)/);
  if (!match) return 1;
  return Math.max(1, parseInt(match[1], 10));
}

// 🔧 Map your form's service answer to a ServiceType.
export function fbServiceTypeFromLabel(label: string): ServiceType {
  if (!label) return 'standard';
  const lower = label.toLowerCase();
  if (lower.includes('move')) return 'move-in-out';
  if (lower.includes('first') || lower.includes('deep')) return 'first-time';
  return 'standard';
}

// 🔧 Map your form's frequency answer to a Frequency.
export function fbFrequencyFromLabel(label: string): Frequency {
  if (!label) return 'one-time';
  const lower = label.toLowerCase();
  if (lower.includes('week') && !lower.includes('bi') && !lower.includes('every other')) return 'weekly';
  if (lower.includes('bi-week') || lower.includes('biweek') || lower.includes('every other')) return 'biweekly';
  if (lower.includes('month')) return 'monthly';
  return 'one-time';
}

export interface FbPriceResult {
  bracket: SqftBracket;
  basePrice: number;
  bathroomAddon: number;
  subtotal: number;
  discount: number;
  discountedSubtotal: number;
  isCustomQuote: boolean;
  serviceLabel: string; // for SMS display
}

export function calculateFbPrice(
  sqftLabel: string,
  bathroomLabel: string,
  serviceLabel: string,
  frequencyLabel: string = ''
): FbPriceResult {
  const bracket = sqftToBracket(sqftLabel);
  const bathrooms = parseBathrooms(bathroomLabel);
  const serviceType = fbServiceTypeFromLabel(serviceLabel);
  const frequency = fbFrequencyFromLabel(frequencyLabel);

  if (bracket === 'custom') {
    return {
      bracket, basePrice: 0, bathroomAddon: 0, subtotal: 0,
      discount: 0, discountedSubtotal: 0, isCustomQuote: true,
      serviceLabel: serviceLabel || 'Standard Clean',
    };
  }

  const standardBase = SQFT_STANDARD_BASE[bracket];
  let basePrice: number;
  let displayLabel: string;
  if (serviceType === 'first-time') {
    basePrice = standardBase + SQFT_FTC_EXTRA[bracket];
    displayLabel = 'First Time Clean';
  } else if (serviceType === 'move-in-out') {
    basePrice = standardBase + SQFT_MIO_EXTRA[bracket];
    displayLabel = 'Move In/Out Clean';
  } else {
    basePrice = standardBase;
    displayLabel = 'Standard Clean';
  }

  const bathroomAddon = Math.max(0, bathrooms - 1) * BATHROOM_ADDON;
  const subtotal = basePrice + bathroomAddon;

  const discountRate = serviceType === 'standard' ? (FREQUENCY_DISCOUNT[frequency] ?? 0) : 0;
  const discount = Math.round(subtotal * discountRate);
  const discountedSubtotal = subtotal - discount;

  return {
    bracket, basePrice, bathroomAddon, subtotal,
    discount, discountedSubtotal, isCustomQuote: false, serviceLabel: displayLabel,
  };
}

export function formatFbPriceForSms(result: FbPriceResult): string {
  if (result.isCustomQuote) return 'a custom quote. I\'ll follow up shortly with details';
  return `$${result.discountedSubtotal} (+ GST)`; // 🔧 drop "+ GST" if you don't charge tax
}
```

---

## `src/fb-sms-template.ts` 🔧 TENANT CONFIG — edit your wording here

Two messages: the one the **lead** gets (their quote) and the one **you** get (full
details). **Rewrite the copy, the sender name, and the booking link** for your business.

```typescript
// SMS templates for Facebook leads. 🔧 TENANT CONFIG — edit copy, name, link.

import type { ParsedFbLead } from './facebook';
import { calculateFbPrice, formatFbPriceForSms } from './pricing';

// Owner-facing notification — full snapshot so you never dig through email.
export function fbLeadOwnerNotification(
  lead: ParsedFbLead,
  meta: { ad_id?: string; form_id?: string }
): string {
  const priceResult = calculateFbPrice(
    lead.squareFootage, lead.bathrooms, lead.serviceType, lead.serviceType
  );
  const priceStr = priceResult.isCustomQuote
    ? 'CUSTOM (3000+ sqft)'
    : `$${priceResult.discountedSubtotal} + GST`;

  const lines = [
    `🧹 NEW FB LEAD`,
    ``,
    `${lead.fullName || 'Unknown'}`,
    `${lead.phone || 'no phone'}`,
    `${lead.email || 'no email'}`,
    ``,
    `Service: ${lead.serviceType || '?'}`,
    `Timing: ${lead.timing || '?'}`,
    `Size: ${lead.squareFootage || '?'} sqft`,
    `Baths: ${lead.bathrooms || '?'}`,
    ``,
    `Quoted: ${priceStr}`,
  ];
  if (meta.ad_id) lines.push(`Ad: ${meta.ad_id}`); // ad attribution — keep this
  return lines.join('\n');
}

// Lead-facing SMS — the auto-quote. 🔧 Change "Adura", "NeatNow Cleaning",
// and the booking link to your own.
export function fbLeadSms(lead: ParsedFbLead): string {
  const name = lead.firstName || 'there';
  const priceResult = calculateFbPrice(
    lead.squareFootage, lead.bathrooms, lead.serviceType, lead.serviceType
  );
  const priceStr = formatFbPriceForSms(priceResult);
  const sqftLine = lead.squareFootage ? `~${lead.squareFootage} sqft` : '';
  const bathLine = lead.bathrooms ? `${lead.bathrooms} bath` : '';
  const detailsLine = [sqftLine, bathLine].filter(Boolean).join(', ');

  if (priceResult.isCustomQuote) {
    return [
      `Hi ${name}, this is Adura from NeatNow Cleaning.`,
      '',
      `Thanks for requesting a quote on Facebook!`,
      '',
      `For a home your size (${detailsLine}), I'll send you a custom quote within a few hours.`,
      '',
      `Questions? Just reply, I respond personally.`,
    ].join('\n');
  }

  return [
    `Hi ${name}, this is Adura from NeatNow Cleaning.`,
    '',
    `Thanks for requesting a quote on Facebook!`,
    '',
    `Based on what you shared (${detailsLine}, ${priceResult.serviceLabel}), your estimated price is ${priceStr}.`,
    '',
    `You can book your preferred date online, no walkthrough needed: https://example.com/book/`,
    '',
    `Questions? Just reply, I respond personally.`,
  ].join('\n');
}
```

---

## What to edit for *your* business — checklist

Everything else is engine. These are the only spots you touch:

- [ ] **`wrangler.toml`** → `name` (your worker name), and every `[vars]` value:
      `OPENPHONE_FROM_NUMBER`, `FORMSPREE_ENDPOINT`, `ALLOWED_ORIGINS`,
      `FB_GRAPH_API_VERSION`, `FB_OWNER_NOTIFY_PHONE`.
- [ ] **`src/pricing.ts`** → the three price tables (`SQFT_STANDARD_BASE`, `SQFT_FTC_EXTRA`,
      `SQFT_MIO_EXTRA`), `BATHROOM_ADDON`, `GST_RATE`, `FREQUENCY_DISCOUNT`, and the bracket
      boundaries in `sqftToBracket` if your form uses different size bands.
- [ ] **`src/fb-sms-template.ts`** → the sender name, business name, booking link, and any
      copy wording in both messages.
- [ ] **`src/index.ts → sendOpenPhoneSms`** → *only* if you use a non-OpenPhone SMS provider
      (swap URL, auth header, body shape, success status).
- [ ] **`src/facebook.ts → parseLeadFields`** → *only* if your form question labels are so
      different that the keyword matcher misses them; add your keywords.
- [ ] **Set the four secrets** with `wrangler secret put` (never in a file):
      `OPENPHONE_API_KEY`, `FB_VERIFY_TOKEN`, `FB_APP_SECRET`, `FB_PAGE_ACCESS_TOKEN`
      (the never-expiring **System User** token — see the warning box in [04](04-the-worker.md)).

Do **not** edit: the routing, signature verification, lead fetch, or the fail-loud block.
Those are the safety guarantees.

---

[← 04 The worker](04-the-worker.md) · [03 Pricing engine](03-pricing-engine.md) · [05 The Facebook ad →](05-facebook-ad.md) · [06 Go live & monitor →](06-go-live-and-monitor.md)
