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.
| Band | Name | Rule | Fee | Min job value |
|---|---|---|---|---|
| 0 – 10 mi | Local | accept | $0 | $0 |
| 10 – 20 mi | Near | accept_with_fee | $10.00 | $0 |
| 20 – 30 mi | Far | high_value_only | $20.00 | $80.00 |
| 30+ mi | Out of range | reject | — | — |
- 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.
- 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
- 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
- 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
- 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
- 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"
- 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
- 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?+
What about commercial accounts further out — those are big jobs.+
My market is sparse — 10 miles is barely a neighborhood, not a service-area band.+
Why haversine instead of routed distance?+
Can a customer game the zone check by entering a different address?+
How do I see which bookings hit the zone fee vs the base price?+
What happens to a customer who gets rejected — do you log them anywhere?+
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