Spaces:
Running
Running
| """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" | |