Stop driving 25 miles for $40 jobs

Groundcut’s four-tier service zones do the math for you. Each zone has a rule, a distance fee, and a per-zone job-value minimum — the booking page enforces them before a slot is ever offered. The default setup is a sane starting point; you edit every band in /admin/zones.

Below: the default zones, the seven mechanics behind them, and the column / route citation for each one.

The default four-zone setup

Seeded on every new tenant. Tune to your market in /admin/zones.

BandNameRuleFeeMin job value
0 – 10 miLocalaccept$0$0
10 – 20 miNearaccept_with_fee$10.00$0
20 – 30 miFarhigh_value_only$20.00$80.00
30+ miOut of rangereject
  • 0 – 10 mi (Local)Default — quoted at the standard rate with no distance fee.
  • 10 – 20 mi (Near)$10 added to the quote as a "Distance fee" line item before the customer sees the total.
  • 20 – 30 mi (Far)Booking only proceeds if the quoted price clears $80. Otherwise the zone-check API returns accepted=false with a clean reject_reason.
  • 30+ mi (Out of range)Hard reject. Customer sees "outside our service area" and the booking flow ends. Enforced by tenants.settings.max_distance_miles (default 25) before zone evaluation even runs.

The seven mechanics behind the zone check

Each piece is a shipped column or route. Audit them yourself.

  1. 01

    Distance is great-circle from your home base

    When the customer enters their address, the geocoder returns lat/lng. The zone-check API computes the haversine distance from your `home_lat`/`home_lng` (set in `/admin/settings`) to the property. That value is the input to every zone decision. Haversine undercounts road-network drive by 10–20% in dense suburbs, which we’d rather absorb than pay for a Mapbox Matrix API call on every quote — tune your band edges up 10–15% if you prefer to think in routed miles.

    distanceMiles() in apps/web/app/api/zone-check/route.ts; tenants.settings.home_lat / home_lng

  2. 02

    max_distance_miles is the hard cap

    Before any zone matching runs, the API checks the property distance against `tenants.settings.max_distance_miles` (default 25). Beyond it, the response is `rule: 'reject'` with `zone_name: 'Out of range'` and a `reject_reason` that includes the actual distance. The booking page renders the reject_reason and the flow ends — no zone is selected, no quote is attempted.

    tenants.settings.max_distance_miles enforced in /api/zone-check

  3. 03

    Zones are ordered rows, matched by distance band

    Inside the cap, the API loads your zones from the `zones` table ordered by `sort_order`. The match is index = `Math.floor(distance / (maxDistance / zones.length))` — so four zones across a 25-mile cap gives you 6.25-mile bands by default. You change the bands by adjusting how many zone rows exist; four is a sensible default, but a tight urban operator might use three (Local / Near / Out) and a wide-area mower might use five.

    zones.sort_order; zone selection logic in /api/zone-check

  4. 04

    Four rules cover the operational cases

    The `zones.rule` column is an enum: `accept` (no fee, no min), `accept_with_fee` (adds `distance_fee` as a line item), `high_value_only` (only accepts if `quoted_price >= min_job_value`), and `reject` (always rejects, regardless of quote). The names map to real operator decisions — these aren’t arbitrary flags, they’re the four meaningful answers to "what do we do with bookings in this band."

    zones.rule enum: accept | accept_with_fee | high_value_only | reject

  5. 05

    distance_fee lands as a quote line item

    When a zone has `accept_with_fee`, the `distance_fee` is added to the quote as `input.zone_fee` in the pricing engine and rendered on the customer-facing breakdown as "Distance fee, $10". The fee rides into `quoted_price` before the floor is applied, so a near-zone mow that would otherwise floor at $50 lands at $60 with the fee visible — and the customer pays $60 at booking, not $50 with a "we forgot to charge for drive time" call later.

    zones.distance_fee → input.zone_fee in calculateQuote; line item "Distance fee"

  6. 06

    min_job_value gates the high-value-only band

    For a `high_value_only` zone, the API compares the `quoted_price` against `zones.min_job_value`. Below the minimum, the response is `accepted: false` with a `reject_reason` ("Job value $52 is below the $80 minimum for this zone"). The booking page hides slot pickers and shows a clean message — the customer can still call, but the self-service flow ends. A large lawn or a hedge-trim package can clear the gate; a small front-only mow cannot.

    zones.min_job_value compared against polygon-driven quoted_price in /api/zone-check

  7. 07

    Edit everything in /admin/zones

    The zones panel shows your current zones in order, with editable name, rule, distance fee, minimum job value, and sort order. You can add a zone, drop a zone, or change a rule mid-season — the new configuration applies to the next booking. Existing recurring schedules carry their own snapshotted price and aren’t affected by a zone-rule change; the booking-page enforcement is for new bookings.

    Admin URL: /admin/zones (renders apps/web/app/admin/zones/ZonesPanel.tsx)

Service-area-limits FAQs

What about a really good repeat customer who moved to a house outside my service area?+
Two outs. First, the zone check runs on the public booking flow, not on admin-side scheduling — you can create a one-off or recurring job for them manually in `/admin/jobs`/`/admin/recurring` without going through the zone check. Second, you can widen the bands in `/admin/zones` for your market: raise `max_distance_miles` in settings or drop the rule on the far band from `high_value_only` to `accept_with_fee`. The defaults are a starting point, not a constraint.
What about commercial accounts further out — those are big jobs.+
Use the `high_value_only` rule and set `min_job_value` to the floor that makes the drive worth it. An $80 minimum at 25 miles, a $150 minimum at 35 miles, a $300 minimum at 50 miles. The booking page only accepts the bookings that clear the gate — a commercial property that quotes at $180 books through; a small residential that quotes at $48 doesn’t. The commercial bookings you do win pay for the drive; the residential noise you would have wasted on doesn’t reach you.
My market is sparse — 10 miles is barely a neighborhood, not a service-area band.+
Edit the bands. Add zone rows in `/admin/zones` covering 0–20, 20–40, 40–60, and raise `max_distance_miles` in `/admin/settings` to match. A rural operator might run accept 0–25, accept-with-fee 25–50 with a $25 fee, high-value-only 50–75 with a $200 minimum, reject 75+. The four-band default is for suburban density; the engine doesn’t assume the defaults are correct for your market.
Why haversine instead of routed distance?+
Routed distance lookups (Mapbox Matrix API) cost money per request and add 300–500ms of latency to every quote. Haversine runs in microseconds, is free, and is wrong by a predictable margin in residential markets (10–20% undercount in dense suburbs, less in sparse markets). For the booking-page check — where the result is "is this in your service area" not "exactly how many miles" — that trade-off is fine. The persisted `distance_miles` on `/api/zone-check` is haversine; if you want routed distance for billing or analytics, that’s a separate calculation we haven’t shipped.
Can a customer game the zone check by entering a different address?+
The geocoder snaps to a real address — they can’t enter "anywhere." If they enter a fake address inside your service area and then the crew arrives at the real address outside, the operator can adjust the price on-site (Adjust Price flow on the crew mobile app) with a signed reason, or reject the job and refund. In practice the satellite trace makes this self-correcting: a customer entering a fake address can’t draw a lawn on satellite imagery they don’t recognize, and the booking page flags address-mismatch cases.
How do I see which bookings hit the zone fee vs the base price?+
Every quote stores its `line_items` array on the resulting job — the "Distance fee" line item is right there in the breakdown. Open any job in `/admin/jobs/[id]` and the breakdown shows the area line, the surcharges, the distance fee (if any), and the floor bump. Bookkeeping rolls these up across periods in `/admin/bookkeeping` so you can see total distance-fee revenue by month or quarter.
What happens to a customer who gets rejected — do you log them anywhere?+
No. The zone-check response is returned to the browser and the booking flow ends — there’s no rejected-quote table. If you want to capture far-out leads anyway, swap the rejecting zone for an `accept_with_fee` zone with a steep `distance_fee` and a high `min_job_value`. The booking won’t complete unless they’re serious about the price, and you’ll see the lead in `/admin/jobs` if they go through.

Draw your service-area map in 5 minutes

14-day free trial. Set your home base, edit the default zones, and run a real booking from the edge of your service area to see the math.

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