fix: close price-tamper, premature rider-free, and IST/txn gaps in merged cx/handover work
Reviewed the 10 merged customer-app/base-handover commits and fixed the
defects found:
- HIGH (money): CreateCxBooking let the request body's `estimate` set the
billed price with no server-side check; it flows into Estimatedprice →
ridercharges (miler pay + tenant bill) with no weight re-price, so
{min:1,max:1} settled a delivery at ₹1. Now the client estimate is honoured
only when it matches the server quote within 15%, else the server quote
stands.
- MED: base handover freed the rider and closed the booking-level assignment
after the FIRST parcel of a multi-destination pickup, dropping the remaining
stops and crediting one leg. Now finalized only when no consignment of the
booking is still in the rider's hands.
- MED: inwardedat/completedat were written with time.Now() (UTC) instead of
DBNow() (IST), skewing them ~5h30 vs createdat and the earnings/reconcile
windows. Fixed in the handover, inbound-scan, reconcile and pickup-complete
paths.
- MED: B2C customers got two "miler assigned" pushes on auto-assign (two token
stores) and none on manual assign. Reconciled to one cxstage.Notify on both
paths.
- LOW: ReconcileHubInbound now runs in a transaction and checks its audit
inserts (was returning 200 with a silently-missing history row); CxLogout no
longer reports signedOut when the token revoke fails; a rider-named handover
base far from their reported position is rejected instead of silently
rerouting the parcel to another city.
go build, go vet and go test ./... all pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
This commit is contained in:
@@ -7,7 +7,6 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
@@ -26,6 +25,12 @@ import (
|
||||
// the app's wording, and nothing in the app's wording should leak back in here.
|
||||
// --------------------
|
||||
|
||||
// maxHandoverBaseKM bounds how far a rider may be from a base they EXPLICITLY
|
||||
// name at handover. A rider stands at the base they hand into, so a named base
|
||||
// this far from their reported position is a wrong id (a different city), not a
|
||||
// real handover. Generous enough to never reject two bases in one metro.
|
||||
const maxHandoverBaseKM = 50.0
|
||||
|
||||
// hubHandoverEnabled gates the two-step hub flow: a hub-routed parcel stops at
|
||||
// Created — collected, in the rider's hands, on its way to a base — and only
|
||||
// reaches Inwarded_at_Hub when the handover is actually recorded, by the rider
|
||||
@@ -372,6 +377,32 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
|
||||
return utils.Fail(c, fiber.StatusNotFound, constants.ErrHubNotFound, "hub_id does not match a known base")
|
||||
}
|
||||
|
||||
lat, lon := 0.0, 0.0
|
||||
if req.Latitude != nil {
|
||||
lat = *req.Latitude
|
||||
} else if req.Lat != nil {
|
||||
lat = *req.Lat
|
||||
}
|
||||
if req.Longitude != nil {
|
||||
lon = *req.Longitude
|
||||
} else if req.Lon != nil {
|
||||
lon = *req.Lon
|
||||
}
|
||||
|
||||
// Guard a fat-fingered base id from silently rerouting the parcel to a base in
|
||||
// the wrong city. Only a base the rider EXPLICITLY names (not the routed
|
||||
// default) is checked, and only when they report their position and the base
|
||||
// has real coordinates: a rider is physically at the base they hand into, so a
|
||||
// named base far from where they stand is a wrong id, not a real handover.
|
||||
riderNamedHub := req.HubID != nil || req.HubIDAlt != nil
|
||||
routedHub := consignment.Currenthubid != nil && hub.Hubid == *consignment.Currenthubid
|
||||
if riderNamedHub && !routedHub && (lat != 0 || lon != 0) && hub.Latitude != 0 && hub.Longitude != 0 {
|
||||
if km := haversineKM(lat, lon, hub.Latitude, hub.Longitude); km > maxHandoverBaseKM {
|
||||
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
|
||||
fmt.Sprintf("selected base %s is %.0f km from your location — check the base before handing over", hub.Hubname, km))
|
||||
}
|
||||
}
|
||||
|
||||
// Already inwarded: answer with the state that stands rather than failing, so
|
||||
// a retry after a dropped response confirms rather than errors. This is also
|
||||
// what a rider on the compatibility flow hits every time — there,
|
||||
@@ -397,19 +428,10 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
|
||||
fmt.Sprintf("consignment is %s — it cannot be handed over at a base from this state", consignment.Status))
|
||||
}
|
||||
|
||||
lat, lon := 0.0, 0.0
|
||||
if req.Latitude != nil {
|
||||
lat = *req.Latitude
|
||||
} else if req.Lat != nil {
|
||||
lat = *req.Lat
|
||||
}
|
||||
if req.Longitude != nil {
|
||||
lon = *req.Longitude
|
||||
} else if req.Lon != nil {
|
||||
lon = *req.Lon
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// IST wall-clock, matching createdat/updatedat and the DBNow() convention, so
|
||||
// inwardedat lines up with the other timestamps base reconciliation and the
|
||||
// earnings "today" window compare it against.
|
||||
now := utils.DBNow()
|
||||
tx := db.DB.Begin()
|
||||
|
||||
consignment.Status = constants.ConsignmentInwardedAtHub
|
||||
@@ -448,40 +470,69 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
|
||||
// multi-destination pickup, so joining on it found nothing for orders 2..N
|
||||
// — and an intercity rider handing in the second parcel of a three-stop
|
||||
// pickup had their assignment left open and their distance recorded as zero.
|
||||
// Close the rider's booking-level assignment and free them ONLY once every
|
||||
// parcel from this pickup has left their hands. A customer-app booking is one
|
||||
// booking → N destinations → N consignments but a single BookingAssignment;
|
||||
// closing on the FIRST handover freed the rider and dropped the remaining
|
||||
// stops from the sequencer while parcels 2..N were still on them, crediting
|
||||
// only the first leg. So finalize only when no consignment of this booking is
|
||||
// still in a rider-carrying state (this one is already Inwarded_at_Hub above).
|
||||
finalizeRiderLeg := true
|
||||
if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil {
|
||||
booking := *bookingPtr
|
||||
dropLat, dropLon := lat, lon
|
||||
if dropLat == 0 && dropLon == 0 {
|
||||
dropLat, dropLon = hub.Latitude, hub.Longitude
|
||||
}
|
||||
riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon)
|
||||
|
||||
orderAmount := 0.0
|
||||
var serviceOpt models.BookingServiceOption
|
||||
if tx.Where("bookingid = ?", booking.Bookingid).Order("createdat DESC").
|
||||
First(&serviceOpt).Error == nil {
|
||||
orderAmount = serviceOpt.Estimatedprice
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.BookingAssignment{}).
|
||||
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
|
||||
booking.Bookingid, milerUserID,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||
Updates(map[string]interface{}{
|
||||
"assignmentstatus": constants.AssignmentCompleted,
|
||||
"completedat": now,
|
||||
"riderkms": riderKms,
|
||||
"ridercharges": orderAmount,
|
||||
}).Error; err != nil {
|
||||
var carrying int64
|
||||
if err := tx.Model(&models.Consignment{}).
|
||||
Joins("JOIN bookingdestinations bd ON bd.consignmentid = consignments.consignmentid").
|
||||
Where("bd.bookingid = ? AND consignments.status IN ?",
|
||||
booking.Bookingid,
|
||||
[]string{constants.ConsignmentCreated, constants.ConsignmentCollectedByMiler, constants.ConsignmentOutForDelivery}).
|
||||
Count(&carrying).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to close assignment")
|
||||
return utils.Internal(c, "failed to check the booking's remaining parcels")
|
||||
}
|
||||
|
||||
if carrying > 0 {
|
||||
// Rider still carries other parcels from this pickup: leave the
|
||||
// assignment open and the rider on the job. The leg is credited and the
|
||||
// rider freed at the final handover.
|
||||
finalizeRiderLeg = false
|
||||
} else {
|
||||
dropLat, dropLon := lat, lon
|
||||
if dropLat == 0 && dropLon == 0 {
|
||||
dropLat, dropLon = hub.Latitude, hub.Longitude
|
||||
}
|
||||
riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon)
|
||||
|
||||
orderAmount := 0.0
|
||||
var serviceOpt models.BookingServiceOption
|
||||
if tx.Where("bookingid = ?", booking.Bookingid).Order("createdat DESC").
|
||||
First(&serviceOpt).Error == nil {
|
||||
orderAmount = serviceOpt.Estimatedprice
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.BookingAssignment{}).
|
||||
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
|
||||
booking.Bookingid, milerUserID,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||
Updates(map[string]interface{}{
|
||||
"assignmentstatus": constants.AssignmentCompleted,
|
||||
"completedat": now,
|
||||
"riderkms": riderKms,
|
||||
"ridercharges": orderAmount,
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to close assignment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
||||
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to update miler availability")
|
||||
if finalizeRiderLeg {
|
||||
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
||||
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to update miler availability")
|
||||
}
|
||||
}
|
||||
|
||||
// The customer's "In transit" milestone. Recorded against THIS order, not
|
||||
|
||||
Reference in New Issue
Block a user