lawn-estimator-dev / tests /test_pricing.py
TempuraML's picture
feat: WS5 — service bundles (config builder → widget packages → Overview) (#138)
7507549 unverified
Raw
History Blame Contribute Delete
5.99 kB
"""Pricing engine — config-driven per-service prices, floored at each minimum."""
from __future__ import annotations
import pytest
from lawn_estimator.pricing import (
Bundle,
PricingConfig,
Service,
compute_bundle_price,
compute_pricing,
load_default_config,
price_service,
)
CONFIG = PricingConfig(
company="Test Lawn Co", currency="USD", presentation="estimate",
services=(
Service("mowing", "Mowing", 5, 45),
Service("fertilizer", "Fertilizer", 9, 60),
Service("aeration", "Aeration", 20, 75),
Service("overseeding", "Overseeding", 25, 0, requires="aeration"),
),
)
def _by_id(items):
return {i["service"]: i for i in items}
def test_small_lawn_hits_minimums():
p = compute_pricing({"lawn_sqft": 5000}, CONFIG)
items = _by_id(p["line_items"])
assert items["mowing"]["price"] == 45.0 and items["mowing"]["min_applied"] is True
assert items["fertilizer"]["price"] == 60.0 and items["fertilizer"]["min_applied"] is True
assert items["aeration"]["price"] == 100.0 and items["aeration"]["min_applied"] is False
assert items["overseeding"]["price"] == 125.0
assert p["total"] == pytest.approx(330.0)
assert p["currency"] == "USD"
assert p["presentation"] == "estimate"
def test_large_lawn_uses_rates_not_minimums():
p = compute_pricing({"lawn_sqft": 10000}, CONFIG)
items = _by_id(p["line_items"])
assert items["mowing"]["price"] == 50.0
assert items["fertilizer"]["price"] == 90.0
assert items["aeration"]["price"] == 200.0
assert items["overseeding"]["price"] == 250.0
assert all(i["min_applied"] is False for i in p["line_items"])
assert p["total"] == pytest.approx(590.0)
def test_overseeding_is_paired_with_aeration():
p = compute_pricing({"lawn_sqft": 8000}, CONFIG)
assert _by_id(p["line_items"])["overseeding"]["paired_with"] == "aeration"
def test_missing_measurement_skips_service():
p = compute_pricing({}, CONFIG) # no lawn_sqft present
assert p["line_items"] == []
assert p["total"] == 0.0
def test_price_service_rounds_to_cents():
item = price_service(Service("fertilizer", "Fertilizer", 9, 0), 4669.8) # 42.0282
assert item["price"] == 42.03
def test_per_sqft_unit_prices_linearly():
# 4,000 sqft at $0.02/sqft = $80 (min $30 not binding); the line item echoes the unit.
item = price_service(Service("mow", "Mow", 0.02, 30, unit="per_sqft"), 4000)
assert item["price"] == 80.0 and item["min_applied"] is False
assert item["unit"] == "per_sqft" and item["rate"] == 0.02
def test_flat_unit_is_size_independent():
svc = Service("cleanup", "Cleanup", 150, unit="flat")
a = price_service(svc, 1000)
b = price_service(svc, 50000)
assert a["price"] == 150.0 and b["price"] == 150.0 # sqft doesn't change a flat price
assert a["unit"] == "flat"
def test_per_sqft_floored_at_minimum():
# Tiny lot: $0.02 × 800 = $16 stays below the $30 minimum.
item = price_service(Service("mow", "Mow", 0.02, 30, unit="per_sqft"), 800)
assert item["price"] == 30.0 and item["min_applied"] is True
def test_travel_time_factor_scales_price_above_minimum():
# 10,000 sqft mowing at $5/1k = $50 base; a 1.25 travel factor → $62.50 (min $45 not binding).
item = price_service(Service("mowing", "Mowing", 5, 45), 10000, travel_time_factor=1.25)
assert item["price"] == 62.5 and item["min_applied"] is False
def test_travel_time_factor_still_floored_at_minimum():
# Small lot: scaled base ($5/1k × 5000 × 1.1 = $27.50) stays below the $45 minimum.
item = price_service(Service("mowing", "Mowing", 5, 45), 5000, travel_time_factor=1.1)
assert item["price"] == 45.0 and item["min_applied"] is True
def test_compute_pricing_applies_factor_to_all_line_items():
p = compute_pricing({"lawn_sqft": 10000}, CONFIG, travel_time_factor=1.2)
items = _by_id(p["line_items"])
assert items["mowing"]["price"] == 60.0 # 50 × 1.2
assert items["fertilizer"]["price"] == 108.0 # 90 × 1.2
assert p["total"] == pytest.approx((50 + 90 + 200 + 250) * 1.2)
_SVC_BY_ID = {s.id: s for s in CONFIG.services}
def test_bundle_fixed_price_and_savings():
b = Bundle("spring", "Spring Package", ("mowing", "aeration"), "fixed", 100.0, "seasonal")
out = compute_bundle_price(b, _SVC_BY_ID, {"lawn_sqft": 10000})
assert out["subtotal"] == 250.0 # mowing $50 + aeration $200
assert out["price"] == 100.0 and out["savings"] == 150.0
assert out["cadence"] == "seasonal" and out["services"] == ["mowing", "aeration"]
def test_bundle_percent_discount():
b = Bundle("save10", "10% off", ("mowing", "fertilizer"), "percent", 10.0)
out = compute_bundle_price(b, _SVC_BY_ID, {"lawn_sqft": 10000})
assert out["subtotal"] == 140.0 and out["price"] == 126.0 and out["savings"] == 14.0
def test_bundle_dropped_when_no_priceable_service():
b = Bundle("x", "X", ("mowing",), "fixed", 50.0)
assert compute_bundle_price(b, _SVC_BY_ID, {}) is None # no lawn_sqft → no members
def test_compute_pricing_includes_bundles():
cfg = PricingConfig("Co", "USD", "firm", CONFIG.services,
bundles=(Bundle("b", "B", ("mowing",), "fixed", 40.0),))
p = compute_pricing({"lawn_sqft": 10000}, cfg)
assert p["bundles"] and p["bundles"][0]["price"] == 40.0
def test_bundled_default_config_matches_partner_rates():
cfg = load_default_config()
assert cfg is not None
svc = {s.id: s for s in cfg.services}
assert (svc["mowing"].rate_per_1000_sqft, svc["mowing"].min_charge) == (5, 45)
assert (svc["fertilizer"].rate_per_1000_sqft, svc["fertilizer"].min_charge) == (9, 60)
assert (svc["aeration"].rate_per_1000_sqft, svc["aeration"].min_charge) == (20, 75)
assert (svc["overseeding"].rate_per_1000_sqft, svc["overseeding"].min_charge) == (25, 0)
assert svc["overseeding"].requires == "aeration"
assert cfg.presentation == "estimate"