"""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"