Set the floor once. Groundcut enforces it on every quote

A tiny drawing should never produce a tiny quote. Groundcut’s minimum price is a per-service field that floors the area math, the surcharges, and the seasonal adjustments — and a target-hourly-rate flag catches the floor-priced jobs that still won’t clear your hour rate.

Below: six pricing-engine mechanics, what they enforce, and the column or response field that backs each one.

How the floor works in the pricing engine

Every step here is shipped code in packages/shared/src/pricing.ts and rides on the /api/quote response.

  1. 01

    min_price is a per-service field, not a global setting

    Each service in `/admin/services` has its own pricing rules, and `min_price` is one of them. Basic residential mowing might floor at $45; a hedge trim service might floor at $75; a one-off bush removal at $150. The booking page picks the service first, so the floor is always the one that matches what the customer is actually buying — not an averaged minimum that under-prices small premium jobs and over-prices large basic ones.

    services.pricing_rules.min_price (per-service JSON field)

  2. 02

    The floor wins over the area math, every time

    The quote engine first computes a price from `price_per_quarter_acre × (mowable_area_sqft / 10,890)`. Then it applies surcharges, distance fees, and seasonal adjustments. Then `Math.max(price, rules.min_price)` runs — the floor cannot be undercut. A customer who drew a 200-sqft lawn doesn’t pay $0.83; they pay your floor. The floor bump is absorbed into the area line item so the breakdown still sums cleanly on the customer-facing quote.

    packages/shared/src/pricing.ts: Math.max(price, rules.min_price) after all surcharges

  3. 03

    max_price is the matching ceiling

    Pricing rules also have a `max_price` ceiling for the same engine. A customer outlining an absurd area (someone tracing a city block) gets clamped to the cap — the booking doesn’t produce a $4,000 instant quote that locks you into a job you can’t deliver. Big jobs route to the in-person quote flow instead, where you visit, fill in line items, and send a real quote.

    services.pricing_rules.max_price; Math.min(price, rules.max_price) at the end of calculateQuote

  4. 04

    target_hourly_rate flags quotes that clear the floor but still lose money

    A quote can clear your $50 minimum and still be unprofitable — a 90-minute job that nets $48.55 after Stripe is $32/hr, not your $50 target. The pricing engine divides `net_after_fees` by `estimated_time_mins / 60` and returns `below_target_rate: true` on the `/api/quote` response. The flag surfaces on the booking page (configurable), the admin job detail, and the operator card. The minimum protects against $1 quotes; the target rate protects against floor-priced jobs that still won’t clear.

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

  5. 05

    The quote response surfaces both numbers, every time

    The `/api/quote` response returns `quoted_price`, `net_after_fees`, `line_items`, `below_target_rate`, `estimated_time_mins`, `equipment_cost`, `card_fee_included`, `mowable_area_sqft`, `mowable_area_acres`, `service_name`, `distance_miles`, and `seasonal_label`. The booking page renders the line items so the customer sees how the quote was built (Area $X, Floor bump rolled in, Distance fee $Y, Surcharge $Z). No black-box pricing.

    POST /api/quote returns the full QuoteResult shape on every request

  6. 06

    Card-fee passthrough is opt-in and respects the floor

    If you turn on `pass_card_fee_to_customer` at the service level, the engine adds Stripe’s 2.9% + 30¢ on top of the quoted price so you net the full quoted amount. The floor still applies to the pre-passthrough price — the passthrough is layered on after, never used to dodge the floor. `card_fee_included: true` rides on the quote response so the booking page can display "$X (card fee included)" cleanly.

    pass_card_fee_to_customer flag; STRIPE_PCT + STRIPE_FLAT applied after min_price floor in pricing.ts

“What about big customers who want a discount?”

The discount conversation comes up in every lawn business. A commercial account wants a per-job rate $10 below your residential floor. A long-time customer wants a “loyalty rate.” Your neighbor wants the buddy price. The minimum-price floor sits in the booking-page flow — it doesn’t have to apply to every customer.

Recurring price overrides take precedence. Set a recurring schedule with a lower price for that customer and the booking-page floor never enters the picture — the recurring schedule carries its own price snapshot through every generated job. The floor is for new-customer instant quotes, not for established relationships.

customer_price_override on a one-off job. From `/admin/jobs` you can create a job with a custom price; the engine clamps it at your floor so a typo can’t zero the job, but otherwise the override wins. The quote-line breakdown shows “Custom rate” and the booking-page math is bypassed.

On-site adjustments are signed and auditable. The crew can adjust the price on arrival if the customer pushes back; increases require a signature, decreases don’t. Every adjustment writes to the audit log with the actor, the before/after, and the reason.

Where each field lives

  • /admin/services — the service card has Min price, Max price, Price-per-quarter-acre, Target hourly rate, and the surcharge fields. Edit one service to test, save, and refresh the booking page to see the new math.
  • services.pricing_rules — the underlying JSON column. `min_price` and `max_price` are required; `target_hourly_rate` is optional (`below_target_rate` returns `false` when it’s null).
  • POST /api/quote — the runtime endpoint. Returns the full quote shape including the floor-applied price, the line-item breakdown, and the target-rate flag.

Minimum-job-price FAQs

What about a long-time customer who wants $10 off the minimum?+
Two outs. First, the operator can override the price on-site with the Adjust Price flow in the crew mobile app — every adjustment is logged with a reason and a signature for increases. Second, you can book that customer manually from `/admin/jobs` and set `customer_price_override` on the line, which uses the override as the quoted price (clamped at your `min_price` floor so a typo doesn’t zero-out the job). Recurring schedules carry their own price snapshot from when they were set up, so an existing recurring customer at $40 stays at $40 even if you raise the booking-page minimum to $50 mid-season.
How does the floor interact with a customer who draws a tiny lawn on purpose?+
They pay your floor — that’s the whole point. The area math returns a sub-floor number, the engine clamps it to `min_price`, and the line items show the bump rolled into the area line so the customer sees a coherent quote. The crew arrives, sees that the real mowable area is bigger than what was drawn, and adjusts the price on-site with a signature for the delta. The floor is a safety net against $1 quotes; the on-site adjust handles the gap between drawn area and real area.
Can I have a different floor for weekly vs one-time customers?+
Not at the `min_price` field today — that’s per-service. The recurring-pricing override fields (`monthly_price_weekly_override`, `monthly_price_biweekly_override`, `monthly_price_monthly_override`) sit on the service and let you offer a different price for recurring frequencies. So you can set a $55 one-time floor and a $45 weekly recurring price; the recurring price replaces the area math for recurring bookings, so it acts like a frequency-specific floor.
Where does the floor live in the database vs the UI?+
It lives in the `pricing_rules` JSON column on the `services` table — same row as `price_per_quarter_acre`, `max_price`, `target_hourly_rate`, and the surcharges. The admin UI is at `/admin/services`; each service card has a "Min price" field. The booking page reads it through `/api/quote` and never directly — you cannot quote without a min_price set (the field is required in `PricingRules`).
Does the minimum apply to in-person quotes too?+
No. In-person quotes (services with `quote_type=in_person`) skip the instant-quote engine entirely. You visit the property, fill in line items in `/admin/jobs/[id]`, set an optional deposit, and send the quote. The minimum is an instant-quote concept — it doesn’t apply to a quote you wrote by hand. If you want a floor on in-person quotes too, the operator workflow is to refuse low-dollar jobs at the in-person request step instead of producing a low-dollar quote.
What if my market is so price-sensitive that the floor loses me bookings?+
Lower it and re-run the math. The plan-fit point is that you control the floor by service in `/admin/services` — there’s no hard-coded Groundcut minimum, just whatever you set. If you’re seeing high abandon rates at $50 and lower at $40, the floor is doing what it should: telling you where price elasticity lives. Use that signal to tune the floor, then tune the `target_hourly_rate` to match — and let the flag tell you which floor-priced bookings still aren’t clearing your hour rate.
How is this different from just setting a high price-per-quarter-acre?+
Price-per-quarter-acre scales the quote with lawn size — a 0.4-acre lot pays more than a 0.2-acre lot. The floor protects the small end: when the area math is below your minimum, the floor kicks in. Without a floor, your $200 quarter-acre rate produces a $5 quote for a 250-sqft front strip, and a tap-tap-tap drawing produces $0.50. With a floor, both produce your minimum. They’re different jobs (scaling vs flooring) and both belong on the service.

Stop quoting below your floor

14-day free trial. Set min_price, max_price, and target_hourly_rate on each service; run a few real bookings; see the engine clamp small lawns to your floor and flag the rest.

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