package controllers import ( "fmt" "math" "strings" "doormile/db" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" ) // Fare estimate — §7 of the contract. // // The number here is an ESTIMATE RANGE and never a final price. Weight is never // collected from the customer: the miler weighs and photographs each package at // the door, and that is when the price settles. Everything shown before that is // a promise about a band, which is why this returns min/max rather than a // figure. // // Called on every route and package-count change, so it stays cheap: the // pricing slab comes out of the same Redis-warmed cache the public price check // uses, and the distance is straight-line rather than a routing call. // cxAssumedKgPerPackage is the weight band the estimate is priced against // before anything has been weighed. It is the ceiling of the standard box the // copy promises ("Standard box (up to 3 kg)"), so the customer is quoted the // top of the band they were shown rather than an optimistic guess that the // settled price then exceeds. const cxAssumedKgPerPackage = 3.0 // cxMultiStopUpliftPct is charged per additional destination on one pickup. // One visit collecting for three places is one visit, so the uplift is well // under three times the price — but the parcels still travel three separate // journeys after the hub, and pricing them as one would undercharge the part // that actually costs money. const cxMultiStopUpliftPct = 0.35 // cxFallbackBase / cxFallbackPerKm / cxFallbackPerKg reproduce the estimate the // booking path already falls back to when no pricing rule matches, so an // unpriced lane quotes the same number it charges. const ( cxFallbackBase = 50.0 cxFallbackPerKm = 5.0 cxFallbackPerKg = 10.0 ) type cxEstimateDestination struct { StateCode string `json:"stateCode"` DistrictCode string `json:"districtCode"` PackageCount int `json:"packageCount"` } type cxEstimateRequest struct { Pickup struct { Lat float64 `json:"lat"` Lng float64 `json:"lng"` } `json:"pickup"` Destinations []cxEstimateDestination `json:"destinations"` } // cxQuote is what both the estimate endpoint and the booking create path work // from, so the number quoted on Review is the number stored on the booking. type cxQuote struct { Min int Max int PaymentMethod string Parcel string RouteKM float64 } // EstimateCxFare prices a whole pickup — one visit, every destination. func EstimateCxFare(c *fiber.Ctx) error { var req cxEstimateRequest if err := c.BodyParser(&req); err != nil { return utils.CxBadRequest(c, "We could not read that request") } if len(req.Destinations) == 0 { return utils.CxBadRequest(c, "Add at least one destination") } quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, req.Destinations) // Volatile enough to be worth a short cache, stable enough that the same // route typed twice should not re-price. c.Set("Cache-Control", "max-age=60") return utils.CxOK(c, fiber.Map{ "min": quote.Min, "max": quote.Max, "paymentMethod": quote.PaymentMethod, "parcel": quote.Parcel, "routeKm": quote.RouteKM, }) } // quoteCxPickup prices one pickup visit across all of its destinations. // // The farthest destination sets the route distance shown on the outline and the // receipt; every destination contributes its own leg price, and the additional // stops carry an uplift rather than a full second visit. func quoteCxPickup(pickupLat, pickupLng float64, destinations []cxEstimateDestination) cxQuote { districts := loadDistricts(destinations) var totalMin, totalMax, maxRouteKM float64 totalPackages := 0 for i, d := range destinations { packages := d.PackageCount if packages < 1 { packages = 1 } totalPackages += packages district, known := districts[strings.ToUpper(strings.TrimSpace(d.DistrictCode))] routeKM := 0.0 if known && (district.Centrelatitude != 0 || district.Centrelongitude != 0) && (pickupLat != 0 || pickupLng != 0) { routeKM = calculateDistance(pickupLat, pickupLng, district.Centrelatitude, district.Centrelongitude) } if routeKM > maxRouteKM { maxRouteKM = routeKM } legMin, legMax := priceLeg(pickupLat, pickupLng, district, known, packages, routeKM) // The first destination is the visit; every one after it is an extra // leg on the same visit. if i > 0 { legMin *= cxMultiStopUpliftPct + 1 legMax *= cxMultiStopUpliftPct + 1 } totalMin += legMin totalMax += legMax } // Whole rupees, not paise — "min: 49" renders as ₹49. Rounded outward so // the band the customer is shown always contains the price that settles // inside it. minR := int(math.Floor(totalMin)) maxR := int(math.Ceil(totalMax)) if maxR < minR { maxR = minR } return cxQuote{ Min: minR, Max: maxR, PaymentMethod: "UPI · Cash at doorstep", Parcel: describeParcels(totalPackages), RouteKM: math.Round(maxRouteKM*10) / 10, } } // priceLeg prices one destination's journey. Falls back to the same // distance-and-weight formula the booking path uses when no pricing rule // covers the lane, so an unpriced route still quotes rather than failing — // a failed estimate must never block a booking. func priceLeg(pickupLat, pickupLng float64, district models.ServiceableDistrict, known bool, packages int, routeKM float64) (min, max float64) { weight := float64(packages) * cxAssumedKgPerPackage zone := "National" if known && district.Pincodeprefix != "" { // Zone is resolved from postal prefixes, the same rule the rest of the // pricing engine uses, so an estimate and a settlement agree on which // slab applies. zone = resolveZone(pincodeForPoint(pickupLat, pickupLng), district.Pincodeprefix) } if rules := matchedPricingRules(zone, "Normal", weight); len(rules) > 0 { lo, hi := rules[0].Minprice, rules[0].Maxprice for _, r := range rules[1:] { if r.Minprice < lo { lo = r.Minprice } if r.Maxprice > hi { hi = r.Maxprice } } return lo, hi } base := cxFallbackBase + routeKM*cxFallbackPerKm + weight*cxFallbackPerKg // A ±20% band around the fallback, so the customer still sees a range and // not a false precision the miler's scale is about to contradict. return base * 0.8, base * 1.2 } // matchedPricingRules returns the active rules covering a weight in a zone, // reusing the Redis-warmed slab the public price check reads. func matchedPricingRules(zone, serviceType string, weight float64) []models.DoormilePricing { rules, err := loadFromPostgres(zone, serviceType) if err != nil || len(rules) == 0 { return nil } matched := applyFilters(rules, weight, "General") if len(matched) == 0 { matched = applyFilters(rules, weight, "") } return matched } // loadDistricts fetches every district named in one estimate in a single query. func loadDistricts(destinations []cxEstimateDestination) map[string]models.ServiceableDistrict { codes := make([]string, 0, len(destinations)) seen := map[string]bool{} for _, d := range destinations { code := strings.ToUpper(strings.TrimSpace(d.DistrictCode)) if code != "" && !seen[code] { seen[code] = true codes = append(codes, code) } } out := make(map[string]models.ServiceableDistrict, len(codes)) if len(codes) == 0 { return out } var districts []models.ServiceableDistrict if err := db.DB.Where("districtcode IN ?", codes).Find(&districts).Error; err != nil { utils.Warn("loadDistricts: query failed, pricing on the fallback formula", "error", err) return out } for _, d := range districts { out[d.Districtcode] = d } return out } // pincodeForPoint gives the zone resolver something to work with when the // pickup is a map pin rather than a typed address. Empty is a valid answer — // resolveZone treats an unknown prefix as a different state, which prices the // long way round rather than under-quoting. func pincodeForPoint(lat, lng float64) string { if lat == 0 && lng == 0 { return "" } var hubs []models.Hub if err := db.DB.Select("hubid, pincode, latitude, longitude"). Where("status = ? AND deletedat IS NULL", "Active").Find(&hubs).Error; err != nil { return "" } best := -1.0 pincode := "" for i := range hubs { h := hubs[i] if h.Pincode == "" || (h.Latitude == 0 && h.Longitude == 0) { continue } d := calculateDistance(lat, lng, h.Latitude, h.Longitude) if best < 0 || d < best { best, pincode = d, h.Pincode } } return pincode } // describeParcels is the "what is being priced" line on Review and the receipt. func describeParcels(packages int) string { if packages <= 1 { return fmt.Sprintf("Standard box (up to %.0f kg)", cxAssumedKgPerPackage) } return fmt.Sprintf("%d boxes (up to %.0f kg each)", packages, cxAssumedKgPerPackage) }