Three of every ten lawn quotes are unprofitable before you even drive out

Driving across town for a $40 quote that won’t close is the most expensive marketing spend in a lawn business. Groundcut rejects the unprofitable quotes before they hit your schedule — using zone rules, per-zone job-value minimums, and a target-hourly-rate flag that runs on every quote.

Below: six protections, what they do, and the database column or API route that backs each one.

Six ways Groundcut rejects unprofitable quotes

Each one is a shipped feature with a column, route, or pricing-rule field you can audit.

  1. 01

    Four service-area zones, each with its own rule

    The default zone setup uses four bands by distance from your home base: 0–10mi accept, 10–20mi accept with a $10 distance fee, 20–30mi accept only if the quoted price clears an $80 minimum, 30+ mi reject. Each row in the `zones` table has a `rule` (`accept` / `accept_with_fee` / `high_value_only` / `reject`), a `distance_fee`, and a `min_job_value`. You edit the bands in `/admin/zones` to fit your market — the defaults are a starting point, not a constraint.

    zones.rule, zones.distance_fee, zones.min_job_value (default zones seeded in supabase/seed.sql)

  2. 02

    Per-zone minimum job value blocks low-dollar bookings far out

    When a customer lands in a `high_value_only` zone, the booking page runs the quote first, then checks the quoted price against the zone’s `min_job_value`. If the quote falls below the floor, the zone-check API returns `accepted: false` with a `reject_reason` ("Job value $52 is below the $80 minimum for this zone") and the booking page never offers a slot. The customer sees a clean "outside our service area for that size of job" message — not a confused checkout.

    POST /api/zone-check; zones.min_job_value compared against the polygon-driven quoted_price

  3. 03

    Target hourly rate flag on every quote

    You set `target_hourly_rate` in the service’s pricing rules (in `/admin/services`). The pricing engine divides `net_after_fees` (what you actually keep after Stripe’s 2.9% + 30¢) by `estimated_time_mins / 60` and returns `below_target_rate: true` on quotes that don’t clear the threshold. The flag rides along with the quote response so the booking page, the admin job detail, and the operator queue can all surface it. A $48 mow at 65 minutes on a $40/hr target is flagged the moment the customer finishes their drawing — not three months later in QuickBooks.

    services.pricing_rules.target_hourly_rate → below_target_rate on /api/quote response

  4. 04

    Hard-reject beyond your maximum distance

    Tenant settings carry a `max_distance_miles` value (default 25). Any property whose great-circle distance from your home base exceeds it gets `rule: reject` from `/api/zone-check` — no zone evaluated, no quote attempted. The customer sees a reject message that includes the actual distance, and the booking flow ends before they even start drawing.

    tenants.settings.max_distance_miles enforced in /api/zone-check (haversine distance from home_lat / home_lng)

  5. 05

    Distance fees are baked into the quote, not bolted on after

    When a property falls in an `accept_with_fee` zone, the `distance_fee` on that zone row is added to the quote as a line item ("Distance fee, $10") and rolls into `quoted_price` before the customer ever sees the total. There’s no "I forgot to charge for the drive" conversation later. The fee is visible on the booking page, on the invoice, and on the operator job card.

    zones.distance_fee → input.zone_fee in calculateQuote (packages/shared/src/pricing.ts), line item "Distance fee"

  6. 06

    Capacity awareness so the wrong booking can’t even squeeze in

    The booking page calls `/api/capacity` before showing slot pickers. The check sums `estimated_time_mins` across already-scheduled jobs for the requested date and compares against your workday budget. If the day is full, the slot doesn’t appear. A 90-minute job that pushes you over the daily limit never enters the queue in the first place.

    POST /api/capacity; sums jobs.estimated_time_mins per date against the workday budget

Why this matters more than another sales channel

Most lawn-care software is built around getting more quotes. Groundcut is built around quoting fewer of the wrong ones. The difference shows up in the first season.

A flagged job is information, not a punishment. The target-hourly-rate flag rides on the quote, the job detail, and the operator card. It doesn’t block the booking by default — you decide. Some operators run a quarter with all zones on `accept` and just watch the flag, then tighten the bands once they see where the unprofitable jobs concentrate.

Zones aren’t arbitrary. The default four-band setup is a sane starting point for a residential operator working out of a single shop. The bands are editable in `/admin/zones` — distance, fee, minimum job value, and rule — so you can shape them to your truck count, your fuel cost, and your local competition.

The economic case is the customers you don’t drive to. A $40 mow 22 miles out costs more in fuel + labor + Stripe’s cut than it returns. Rejecting it isn’t a lost sale — it’s an avoided cost.

Where you configure each piece

  • /admin/zones — edit the four-zone default. Add a band, change a `rule`, raise a `min_job_value`, drop the `distance_fee` for a band you decide is too aggressive.
  • /admin/services — set `target_hourly_rate` on each service. The flag is per-service, so basic mowing and full landscape installs can have different targets.
  • /admin/settings — your home-base address (becomes `home_lat` / `home_lng` after geocoding) and `max_distance_miles`. The home base is the anchor for every zone check.
  • /admin/jobs — where flagged jobs land for triage. You can accept, cancel, or rebook from here.

Stop-unprofitable-jobs FAQs

I already turn down low-value jobs by ignoring the phone. Why do I need this?+
Because the cost of low-value jobs isn’t the job — it’s the quote. You drive out, walk the lot, write down a number, drive back, and 30–40% of those quotes ghost. The expensive part already happened. The point of the zone rules and the target-rate flag is that the bad quotes never get a slot to begin with — the customer self-quotes from the booking page, gets a price, and either fits your rules or sees "we don’t cover that" before you’ve burned an hour. The phone doesn’t ring.
What if a great recurring customer moves to a new house that’s now 22 miles out?+
You have two outs. First, you can edit the zones in `/admin/zones` to widen the accept band for your market — the four-zone default is just a starting point, not a constraint. Second, you can book that customer manually from `/admin/jobs` without going through the booking page; the zone check runs on the public booking flow, not on internal admin scheduling. Reserving the rules for new customers and hand-booking the loyal ones is a normal pattern.
How does the target-hourly-rate calculation actually work?+
You set `target_hourly_rate` in `/admin/services` for each service. The quote engine takes `net_after_fees` (the quoted price minus Stripe’s 2.9% + 30¢) and divides it by `estimated_time_mins / 60`. If that result is less than your target, `below_target_rate` comes back `true` on the `/api/quote` response. The booking page can decide whether to still let the customer book (most do — a flagged job is information, not a hard rejection) or to gate it. The flag also surfaces on the admin job detail so you can see, before the crew leaves the yard, that the job won’t hit your target.
Doesn’t rejecting bookings lose me revenue I would have grabbed otherwise?+
Some, yes — and the math is the point. A $50 mow 25 miles out at 60 minutes of cut time + 50 minutes of round-trip drive is a $25/hr gross job. Net of fuel, equipment cost, and Stripe’s cut, it’s closer to break-even. The zones aren’t there to win every job; they’re there to make sure the jobs you do win clear your target. If the math says a band should accept, set it to accept. If it doesn’t, the worst outcome is that a stranger doesn’t book a job you would have lost money on.
Is the distance check accurate or is it as-the-crow-flies?+
It’s great-circle (haversine) distance between your `home_lat`/`home_lng` and the property coordinates from the geocoder. That undercount the road-network drive a bit — typically 10–20% in dense suburbs. We chose haversine for the booking-time check because routed-distance lookups would cost a Mapbox Matrix API call per quote, which adds latency and per-booking cost. If you want the zone bands to reflect routed distance, tune the band edges up by 10–15% to compensate.
What if I want to accept everything and just see the flag, not block the booking?+
Set every zone’s `rule` to `accept` and ignore `min_job_value`. The booking page will quote everything; the target-rate flag will still ride along on quotes that don’t clear your target. You can then triage in `/admin/jobs` — accept the flagged ones manually or cancel them with a "we don’t cover your area for that size of job" note. Some operators prefer the look-then-decide model in their first season and tighten the zones once they have a quarter of data.
Where does the rejected-booking customer go?+
They see your booking page’s "outside service area" message with your contact info. They can still call or email. The data isn’t logged anywhere — there’s no funnel of rejected-quote leads to mine. If you want to capture them, swap the rejecting zone for an `accept_with_fee` zone with a steep fee + high `min_job_value`; the booking won’t land unless they’re serious about the price, and you’ll still see the lead.

Quote fewer of the wrong jobs

14-day free trial. Set your zones, your target hourly rate, and your distance cap; run a few real bookings; see the unprofitable ones get filtered out before they hit your schedule.

14-day free trial · No card required · Cancel any time