Files
doormile_backend/controllers/cxBookingController.go
Suriyakumarvijayanayagam ba2cd2299c 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
2026-09-16 12:16:50 +05:30

944 lines
35 KiB
Go

package controllers
import (
"encoding/json"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/assignment"
"doormile/internal/cxstage"
"doormile/middlewares"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Bookings — §9 of the contract.
//
// A customer books a PICKUP: one visit, 1..N destinations, and no tracking
// number anywhere in this file. Tracking numbers are minted per destination
// when the miler completes the pickup, because until the parcels are in
// someone's hands there is no shipment to track — only an intention to collect
// one.
const (
cxDefaultPageSize = 20
cxMaxPageSize = 50
// cxAbsoluteMaxDestinations is a hard ceiling checked BEFORE any database
// work, independent of the configured per-city cap.
//
// The configured cap (customerbookinglimits.maxdestinations) is the real
// policy and stays authoritative — but reading it costs two queries, and
// the district lookup above it builds a `WHERE districtcode IN (...)` from
// however many entries the caller sent. Without a ceiling, a request
// carrying ten thousand destinations does all of that work before anything
// says no.
//
// Set well above any plausible policy so it never masks the real cap: this
// is a sanity guard on unbounded input, not a product limit.
cxAbsoluteMaxDestinations = 25
)
type cxDetailsInput struct {
Street *string `json:"street"`
Building *string `json:"building"`
Landmark *string `json:"landmark"`
RecipientName *string `json:"recipientName"`
RecipientPhone *string `json:"recipientPhone"`
Instructions *string `json:"instructions"`
Pin *struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
} `json:"pin"`
// CodAmount is money the customer wants collected at this door on their
// behalf. Doormile is the carrier, not the seller.
CodAmount *float64 `json:"codAmount"`
}
type cxCreateBookingRequest struct {
Pickup struct {
Title string `json:"title"`
Sub string `json:"sub"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
} `json:"pickup"`
SlotID string `json:"slotId"`
Destinations []struct {
StateCode string `json:"stateCode"`
DistrictCode string `json:"districtCode"`
PackageCount int `json:"packageCount"`
Details *cxDetailsInput `json:"details"`
} `json:"destinations"`
// Estimate is what the customer was shown on Review. Recorded for dispute
// audit — when the settled price is questioned months later, the number on
// the screen is the fact that matters, not a re-run of today's pricing.
Estimate *struct {
Min int `json:"min"`
Max int `json:"max"`
} `json:"estimate"`
// Remarks is the free-text note the customer adds on Review ("Handle with
// care"). It is top-level in the documented payload
// (docs/customer-app-api-crisp.md) and lands in PickupBooking.Notes, which
// the admin Orders table displays and searches. Without the field here
// BodyParser drops it silently and every customer-app booking reaches the
// console with an empty note.
Remarks string `json:"remarks"`
}
// cxEstimateMatchesQuote reports whether a customer-supplied price band is close
// enough to the server's own quote to be trusted as the agreed price. It guards
// the stored Estimatedprice — which becomes ridercharges at completion — against
// a tampered request body while still honouring an honest estimate that came
// from our estimate endpoint. Rejects negatives, inverted bands, and — when the
// server could not price the pickup (zero quote) — any client number at all,
// since with no server figure to check against the client's would be unbounded.
func cxEstimateMatchesQuote(clientMin, clientMax, quoteMin, quoteMax int) bool {
if clientMin < 0 || clientMax < clientMin {
return false
}
serverMid := float64(quoteMin+quoteMax) / 2
if serverMid <= 0 {
return false
}
clientMid := float64(clientMin+clientMax) / 2
diff := clientMid - serverMid
if diff < 0 {
diff = -diff
}
// 15% of the server midpoint absorbs rounding and minor pricing drift between
// the estimate call and confirm, without letting a materially different
// number through.
return diff <= 0.15*serverMid
}
// CreateCxBooking creates the pickup.
func CreateCxBooking(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var req cxCreateBookingRequest
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
// "No destinations at all" and "a destination is missing its state or
// district" are different problems with different fixes, and the customer
// is shown this message verbatim. Telling someone who added nothing that
// "every destination needs a serviceable state and district" describes a
// problem they do not have and hides the one they do — they need to add a
// destination, not correct one. The estimate endpoint already says this
// correctly; these two now agree.
if len(req.Destinations) == 0 {
return utils.CxBadRequest(c, "Add at least one destination")
}
if strings.TrimSpace(req.SlotID) == "" {
return utils.CxBadRequest(c, "Pick a pickup slot")
}
// Cheapest validation first, before anything reaches the database. A slot id
// carries its own date, so a stale one is provably stale without a query —
// and this is the case an app left open across midnight actually hits.
if CxSlotDateIsPast(req.SlotID) {
return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot")
}
// Unbounded input, bounded before it costs anything. The configured cap
// below is the real policy; this only stops a caller making the server do
// three queries and build an arbitrarily long IN clause to be told no.
if len(req.Destinations) > cxAbsoluteMaxDestinations {
return utils.CxBadRequest(c, "That is more destinations than one pickup can carry")
}
// The pickup point has to be somewhere Doormile actually collects from.
// CityGateMiddleware sniffs the body for a `pickuppincode`, which this
// request shape does not have — its pickup is a title/sub/lat/lng from the
// place search — so it waves every customer booking through. The check is
// done here, against the pincode resolved from the coordinates.
pickupPincode := pincodeForPoint(req.Pickup.Lat, req.Pickup.Lng)
if _, served := middlewares.PincodeInOperatingCity(pickupPincode); !served {
return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable,
"We are not collecting from that area yet")
}
appLocationID := appLocationForPoint(req.Pickup.Lat, req.Pickup.Lng)
maxPackages, maxDestinations := CxBookingLimits(appLocationID)
if len(req.Destinations) > maxDestinations {
return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxDestinations)+" destinations per pickup")
}
// Resolve every district in one query, then validate. Names are copied onto
// the destination rather than joined at read time — the client renders
// "Chennai, Tamil Nadu" straight from the booking.
codes := make([]string, 0, len(req.Destinations))
for _, d := range req.Destinations {
codes = append(codes, strings.ToUpper(strings.TrimSpace(d.DistrictCode)))
}
var districtRows []models.ServiceableDistrict
if err := db.DB.Where("districtcode IN ?", codes).Find(&districtRows).Error; err != nil {
utils.Error("CreateCxBooking: district lookup failed", "error", err)
return utils.CxInternal(c)
}
districts := make(map[string]models.ServiceableDistrict, len(districtRows))
for _, d := range districtRows {
districts[d.Districtcode] = d
}
stateNames, err := cxStateNames(districtRows)
if err != nil {
utils.Error("CreateCxBooking: state lookup failed", "error", err)
return utils.CxInternal(c)
}
totalPackages := 0
for _, d := range req.Destinations {
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
district, ok := districts[code]
if !ok || strings.TrimSpace(d.StateCode) == "" {
return utils.CxBadRequest(c, "Every destination needs a serviceable state and district")
}
if !district.Available {
// The district was open when the customer picked it and closed
// before they confirmed. A distinct code, because the app has a
// specific recovery for it: send them back to change that one
// destination rather than to a generic retry.
return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable,
"That district is no longer available")
}
packages := d.PackageCount
if packages < 1 {
packages = 1
}
totalPackages += packages
}
if totalPackages > maxPackages {
return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxPackages)+" packages per pickup")
}
slotFrom, slotTo, slotOK := ResolveCxSlot(req.SlotID)
if !slotOK {
return utils.CxBadRequest(c, "Pick a pickup slot")
}
// An EXPIRED slot and a FULL slot are different failures and must not share
// a message. A slot id encodes its own date, so an app left open across
// midnight — or one that cached the slot list for a session — sends
// yesterday's window in good faith. Telling that customer the window "just
// filled up" is untrue and points them at the wrong recovery: they need to
// re-fetch the slot list, not try again for a place in a queue.
//
// 400 rather than 409 for the same reason. The contract maps 400/invalid to
// "Pick a pickup slot", which is exactly the action required, while 409 is
// the capacity race below.
if !slotFrom.After(utils.ISTNow()) {
return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot")
}
if !CxSlotHasCapacity(req.SlotID, req.Pickup.Lat, req.Pickup.Lng) {
return utils.CxConflict(c, "That pickup window just filled up")
}
// Price the pickup as one visit. A failed estimate must never block a
// booking, so a zero range is stored rather than an error returned — the
// receipt settles on what the miler weighs regardless.
estimateDestinations := make([]cxEstimateDestination, 0, len(req.Destinations))
for _, d := range req.Destinations {
estimateDestinations = append(estimateDestinations, cxEstimateDestination{
StateCode: d.StateCode,
DistrictCode: d.DistrictCode,
PackageCount: d.PackageCount,
})
}
quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, estimateDestinations)
estimateMin, estimateMax := quote.Min, quote.Max
if req.Estimate != nil && req.Estimate.Max > 0 {
if cxEstimateMatchesQuote(req.Estimate.Min, req.Estimate.Max, quote.Min, quote.Max) {
// The customer's number wins — but only when it agrees with what the
// server independently prices for this pickup. An honest app took its
// estimate from our own estimate endpoint, so it matches; re-pricing at
// confirm time would quietly change the deal for that customer. A
// tampered body (e.g. {min:1,max:1}) does NOT match and must never
// stand, because this midpoint becomes Estimatedprice, which the pickup
// and every handover leg copy verbatim into bookingassignments.ridercharges
// — the miler's pay and the tenant's bill — with no weight re-price.
estimateMin, estimateMax = req.Estimate.Min, req.Estimate.Max
} else {
utils.Warn("CreateCxBooking: client estimate rejected, pricing from server quote",
"customer_id", customerID,
"client_min", req.Estimate.Min, "client_max", req.Estimate.Max,
"quote_min", quote.Min, "quote_max", quote.Max)
}
}
now := utils.DBNow()
pickupFromDB := cxToDBTime(slotFrom)
pickupToDB := cxToDBTime(slotTo)
first := req.Destinations[0]
firstDistrict := districts[strings.ToUpper(strings.TrimSpace(first.DistrictCode))]
booking := models.PickupBooking{
Bookingno: generateBookingNo(),
Appcustomerid: customerID,
Pickupaddress: joinNonEmpty(", ", req.Pickup.Title, req.Pickup.Sub),
Pickuptitle: req.Pickup.Title,
Pickupsub: req.Pickup.Sub,
Pickuppincode: pickupPincode,
Pickuplatitude: req.Pickup.Lat,
Pickuplongitude: req.Pickup.Lng,
// The flat delivery columns mirror destination 0. They are NOT the
// destination list — that lives in bookingdestinations — but the miler
// app, the hub console, the routing code and the hyperlocal check all
// read them, and leaving them empty would make a customer-app booking
// invisible to every one of those. Destination 0 is the one the rider
// is told about first, so it is the one that mirrors.
Deliveryaddress: cxDestinationAddress(first.Details, firstDistrict),
Deliverypincode: firstDistrict.Pincodeprefix,
Deliverylatitude: firstDistrict.Centrelatitude,
Deliverylongitude: firstDistrict.Centrelongitude,
Deliverycity: firstDistrict.Districtname,
Bookingsource: constants.BookingSourceCustomerApp,
Pickupsourcetype: constants.PickupSourceCustomer,
Status: constants.BookingPendingPickup,
Preferredpickupfrom: &pickupFromDB,
Preferredpickupto: &pickupToDB,
Slotid: req.SlotID,
Customerstage: constants.CxStageBooked,
Customerstatus: constants.CxStatusActive,
Estimateminrupees: estimateMin,
Estimatemaxrupees: estimateMax,
Routekm: quote.RouteKM,
Notes: req.Remarks,
Createdat: now,
Updatedat: now,
}
if pin := cxFirstPin(first.Details); pin != nil {
booking.Deliverylatitude, booking.Deliverylongitude = pin[0], pin[1]
}
tx := db.DB.Begin()
if err := tx.Create(&booking).Error; err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not create booking", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
for i, d := range req.Destinations {
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
district := districts[code]
packages := d.PackageCount
if packages < 1 {
packages = 1
}
dest := models.BookingDestination{
Bookingid: booking.Bookingid,
Seq: i,
Statecode: district.Statecode,
Statename: stateNames[district.Statecode],
Districtcode: district.Districtcode,
Districtname: district.Districtname,
Packagecount: packages,
Pincode: district.Pincodeprefix,
Createdat: now,
Updatedat: now,
}
applyCxDetails(&dest, d.Details)
if err := tx.Create(&dest).Error; err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not create destination", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
// One parcel row per package, linked to its destination. The miler
// weighs and photographs each package at the door, so each needs a row
// to be weighed into — and each has to know which order it belongs to.
// Weight is deliberately left at zero: it is never collected from the
// customer, and a guess here would look like a measurement on the
// receipt.
for p := 0; p < packages; p++ {
parcel := models.BookingParcel{
Bookingid: booking.Bookingid,
Bookingdestinationid: &dest.Bookingdestinationid,
Itemcategory: "General",
Createdat: now,
Updatedat: now,
}
if err := tx.Create(&parcel).Error; err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not create parcel", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
}
}
// The service option is what the REST of the platform reads a booking's
// value from, and it is not optional just because the customer surface
// keeps its own estimate columns:
//
// * MilerDeliverConsignment and MilerInwardConsignmentAtHub copy
// Estimatedprice onto BookingAssignment.ridercharges when a leg closes,
// and GET /miler/earnings sums that column — so with no row here every
// customer-app job a rider completes would show ₹0 on their Earnings
// screen.
// * The admin console's Orders list renders serviceoptions[0].estimatedprice
// as the Price column, which would read "N/A" for every customer booking.
//
// The midpoint of the band the customer was shown is the honest figure
// before the miler weighs anything, and it is what lookupDoormilePrice
// returns everywhere else in this codebase.
slaDue := cxToDBTime(slotTo.Add(48 * time.Hour))
estimatedDelivery := cxToDBTime(slotTo.Add(24 * time.Hour))
srvOption := models.BookingServiceOption{
Bookingid: booking.Bookingid,
Servicetype: "Normal",
Estimatedprice: float64(estimateMin+estimateMax) / 2,
Estimateddeliveryat: &estimatedDelivery,
Sladueat: &slaDue,
Createdat: now,
}
if err := tx.Create(&srvOption).Error; err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not create service option", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
Stage: constants.CxStageBooked,
ActorType: constants.CxActorCustomer,
ActorID: &customerID,
Source: "POST /customer/bookings",
At: now,
}); err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not record booked stage", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
if err := tx.Commit().Error; err != nil {
utils.Error("CreateCxBooking: commit failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
// Fire-and-forget, after commit, exactly as the express path does.
go assignment.AssignCustomerMiler(booking.Bookingid)
publishCxBookingCreated(&booking, len(req.Destinations))
return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusCreated)
}
// GetCxBookings backs the Orders tabs, Home's recent list and pull-to-refresh.
func GetCxBookings(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
limit := cxDefaultPageSize
if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 {
limit = v
}
if limit > cxMaxPageSize {
limit = cxMaxPageSize
}
// One filter, applied to both the page and the count, so `total` is the size
// of the tab the customer is actually looking at. Counting every booking
// they have ever made would put "48" above a Cancelled tab holding two rows.
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
scoped := func() *gorm.DB {
q := db.DB.Model(&models.PickupBooking{}).Where("appcustomerid = ?", customerID)
switch status {
case constants.CxStatusActive:
// Rows written before this surface existed carry no customerstatus.
// Treating a null as active keeps a live booking visible rather
// than hiding it from the customer who is waiting on it.
// Parenthesised explicitly rather than relying on AND binding
// tighter than OR — the two readings differ by "shows every
// cancelled booking in the active tab", which is not a thing to
// leave to operator precedence.
q = q.Where("(customerstatus = ?) OR ((customerstatus IS NULL OR customerstatus = '') AND status <> ?)",
constants.CxStatusActive, constants.BookingCancelled)
case constants.CxStatusCompleted:
q = q.Where("customerstatus = ?", constants.CxStatusCompleted)
case constants.CxStatusCancelled:
q = q.Where("customerstatus = ? OR status = ?", constants.CxStatusCancelled, constants.BookingCancelled)
}
return q
}
q := scoped()
// Keyset pagination on the primary key. Offsets drift when a new booking
// lands mid-scroll, which shows the customer the same row twice.
if cursor := strings.TrimSpace(c.Query("cursor")); cursor != "" {
if after, err := strconv.Atoi(cursor); err == nil {
q = q.Where("bookingid < ?", after)
}
}
var bookings []models.PickupBooking
if err := q.Order("bookingid DESC").Limit(limit + 1).Find(&bookings).Error; err != nil {
utils.Error("GetCxBookings: query failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
var nextCursor *string
if len(bookings) > limit {
bookings = bookings[:limit]
next := strconv.Itoa(bookings[len(bookings)-1].Bookingid)
nextCursor = &next
}
bundle := loadCxBundle(bookings)
out := make([]fiber.Map, 0, len(bookings))
for i := range bookings {
out = append(out, renderCxBooking(&bookings[i], bundle))
}
var total int64
if err := scoped().Count(&total).Error; err != nil {
utils.Warn("GetCxBookings: count failed, reporting the page size", "customer_id", customerID, "error", err)
total = int64(len(out))
}
return utils.CxList(c, out, int(total), nextCursor)
}
// GetCxBookingDetail is the canonical read — the tracking screen and the
// receipt are both rendered from it, and it is polled while tracking is open.
func GetCxBookingDetail(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
reference := strings.TrimSpace(c.Params("reference"))
booking, err := cxLoadBooking(customerID, reference)
if err != nil {
return utils.CxNotFound(c, "We could not find that pickup")
}
bundle := loadCxBundle([]models.PickupBooking{*booking})
payload := renderCxBooking(booking, bundle)
// Polled every few seconds while the tracking screen is open. A 304 turns
// most of those polls into a header exchange instead of a full render.
if served := serveIfNotModified(c, payload); served {
return nil
}
c.Set("Cache-Control", "no-cache")
return utils.CxOK(c, payload)
}
// GetCxOrder returns one order by tracking number, for push deep links.
//
// It answers with the whole booking object rather than a slimmer order shape —
// the client already parses this one, and a second shape for the same data is
// a second parser to keep in step.
func GetCxOrder(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
trackingID := strings.TrimSpace(c.Params("trackingId"))
if trackingID == "" {
return utils.CxNotFound(c, "We could not find that order")
}
var dest models.BookingDestination
if err := db.DB.Where("trackingno = ?", trackingID).First(&dest).Error; err != nil {
return utils.CxNotFound(c, "We could not find that order")
}
var booking models.PickupBooking
if err := db.DB.Where("bookingid = ? AND appcustomerid = ?", dest.Bookingid, customerID).
First(&booking).Error; err != nil {
// The tracking number exists but belongs to someone else. Answered as
// not-found rather than forbidden: confirming that a tracking number is
// real tells an enumerating caller something they should not learn.
return utils.CxNotFound(c, "We could not find that order")
}
bundle := loadCxBundle([]models.PickupBooking{booking})
return utils.CxOK(c, renderCxBooking(&booking, bundle))
}
// CancelCxBooking cancels the whole pickup.
//
// Allowed through arrived and refused from picked_up onward. `cancellable` on
// the booking mirrors the same policy so the UI can hide the button, but this
// re-checks — the button state is a hint the client renders from a response
// that may be seconds old, never the authority.
func CancelCxBooking(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
reference := strings.TrimSpace(c.Params("reference"))
var req struct {
Reason string `json:"reason"`
}
_ = c.BodyParser(&req)
booking, err := cxLoadBooking(customerID, reference)
if err != nil {
return utils.CxNotFound(c, "We could not find that pickup")
}
if booking.Customerstatus == constants.CxStatusCancelled ||
booking.Status == constants.BookingCancelled {
// Already cancelled. Answered as success rather than as a conflict: the
// customer asked for a state the booking is already in, and a retry
// over a flaky network must not read as a failure.
return utils.CxOK(c, fiber.Map{
"reference": booking.Bookingno,
"status": constants.CxStatusCancelled,
"cancelReason": booking.Cancelreason,
})
}
stage := booking.Customerstage
if stage == "" {
stage = deriveStageFromStatus(booking)
}
if !constants.CxCancellable(stage) {
return utils.CxConflict(c, "This pickup can no longer be cancelled")
}
reason := strings.TrimSpace(req.Reason)
tx := db.DB.Begin()
if err := cxstage.Cancel(tx, booking.Bookingid, reason,
constants.CxActorCustomer, &customerID, "POST /customer/bookings/{reference}/cancel"); err != nil {
tx.Rollback()
utils.Error("CancelCxBooking: cancel failed", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
// Release the rider. Without this the assignment stays open, the rider
// keeps a stop they must not attempt, and MilerEndDuty refuses to let them
// go off duty while any assignment is still Assigned or Accepted.
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND assignmentstatus IN ?", booking.Bookingid,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCancelled,
"remarks": "cancelled by customer",
}).Error; err != nil {
tx.Rollback()
utils.Error("CancelCxBooking: could not release assignment", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
if booking.Assignedmileruserid != nil {
if err := tx.Model(&models.MilerProfile{}).
Where("userid = ? AND availabilitystatus IN ?", *booking.Assignedmileruserid,
[]string{constants.MilerAssigned, constants.MilerOnPickup, constants.MilerAtCustomer}).
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
tx.Rollback()
utils.Error("CancelCxBooking: could not free the rider", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
}
if err := tx.Commit().Error; err != nil {
utils.Error("CancelCxBooking: commit failed", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
publishCxBookingCancelled(booking, reason)
return utils.CxOK(c, fiber.Map{
"reference": booking.Bookingno,
"status": constants.CxStatusCancelled,
"cancelReason": reason,
})
}
// PatchCxDestination fills in the parts of an address the customer left out.
//
// Accepted until the parcels are collected. After that the shipment's addresses
// are frozen on the consignment and an edit here would change what the customer
// sees without changing where the parcel is going — which is worse than
// refusing.
//
// Writes land on the same booking row the miler app reads addresses from, so a
// correction made while the rider is on their way reaches them.
func PatchCxDestination(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
reference := strings.TrimSpace(c.Params("reference"))
index, err := strconv.Atoi(c.Params("index"))
if err != nil || index < 0 {
return utils.CxNotFound(c, "We could not find that destination")
}
booking, err := cxLoadBooking(customerID, reference)
if err != nil {
return utils.CxNotFound(c, "We could not find that pickup")
}
stage := booking.Customerstage
if stage == "" {
stage = deriveStageFromStatus(booking)
}
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStagePickedUp] {
return utils.CxConflict(c, "Your packages have been collected — these details can no longer be changed")
}
if booking.Customerstatus == constants.CxStatusCancelled || booking.Status == constants.BookingCancelled {
return utils.CxConflict(c, "This pickup was cancelled")
}
var dest models.BookingDestination
if err := db.DB.Where("bookingid = ? AND seq = ?", booking.Bookingid, index).
First(&dest).Error; err != nil {
return utils.CxNotFound(c, "We could not find that destination")
}
var req cxDetailsInput
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
applyCxDetails(&dest, &req)
dest.Updatedat = utils.DBNow()
tx := db.DB.Begin()
if err := tx.Save(&dest).Error; err != nil {
tx.Rollback()
utils.Error("PatchCxDestination: save failed", "destination_id", dest.Bookingdestinationid, "error", err)
return utils.CxInternal(c)
}
// Destination 0 mirrors onto the booking's flat delivery columns, which is
// where the miler app and the routing code look. An edit that only landed
// on bookingdestinations would be invisible to the rider standing at the
// door, which is precisely who it was made for.
if dest.Seq == 0 {
updates := map[string]interface{}{
"deliveryaddress": cxDestinationAddressFromRow(&dest),
"updatedat": utils.DBNow(),
}
if dest.Pinlatitude != nil && dest.Pinlongitude != nil {
updates["deliverylatitude"] = *dest.Pinlatitude
updates["deliverylongitude"] = *dest.Pinlongitude
}
if err := tx.Model(&models.PickupBooking{}).
Where("bookingid = ?", booking.Bookingid).Updates(updates).Error; err != nil {
tx.Rollback()
utils.Error("PatchCxDestination: could not mirror onto booking", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
}
if err := tx.Commit().Error; err != nil {
utils.Error("PatchCxDestination: commit failed", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusOK)
}
// ── Helpers ──────────────────────────────────────────────────────────────────
// cxLoadBooking finds a booking by its customer-facing reference, scoped to the
// caller. Ownership is part of the lookup, not a check afterwards — a query
// that can return someone else's row is one forgotten `if` away from leaking it.
func cxLoadBooking(customerID int, reference string) (*models.PickupBooking, error) {
if reference == "" {
return nil, gorm.ErrRecordNotFound
}
var booking models.PickupBooking
if err := db.DB.Where("bookingno = ? AND appcustomerid = ?", reference, customerID).
First(&booking).Error; err != nil {
return nil, err
}
return &booking, nil
}
// cxRespondWithBooking re-reads and renders, so a create or a patch answers in
// exactly the shape a later GET will.
func cxRespondWithBooking(c *fiber.Ctx, bookingID, status int) error {
var booking models.PickupBooking
if err := db.DB.First(&booking, bookingID).Error; err != nil {
utils.Error("cxRespondWithBooking: reload failed", "booking_id", bookingID, "error", err)
return utils.CxInternal(c)
}
bundle := loadCxBundle([]models.PickupBooking{booking})
payload := renderCxBooking(&booking, bundle)
if status == fiber.StatusCreated {
return utils.CxCreated(c, payload)
}
return utils.CxOK(c, payload)
}
// applyCxDetails writes only the fields actually present in the request. An
// omitted key leaves the stored value alone; an explicit null clears it, which
// is how the customer removes a landmark they no longer want the rider to use.
func applyCxDetails(dest *models.BookingDestination, in *cxDetailsInput) {
if in == nil {
return
}
if in.Street != nil {
dest.Street = strings.TrimSpace(*in.Street)
}
if in.Building != nil {
dest.Building = strings.TrimSpace(*in.Building)
}
if in.Landmark != nil {
dest.Landmark = strings.TrimSpace(*in.Landmark)
}
if in.RecipientName != nil {
dest.Recipientname = strings.TrimSpace(*in.RecipientName)
}
if in.RecipientPhone != nil {
if phone, _, ok := normalizePhone(*in.RecipientPhone); ok {
dest.Recipientphone = phone
} else {
dest.Recipientphone = strings.TrimSpace(*in.RecipientPhone)
}
}
if in.Instructions != nil {
dest.Instructions = strings.TrimSpace(*in.Instructions)
}
if in.Pin != nil {
lat, lng := in.Pin.Lat, in.Pin.Lng
if lat == 0 && lng == 0 {
dest.Pinlatitude, dest.Pinlongitude = nil, nil
} else {
dest.Pinlatitude, dest.Pinlongitude = &lat, &lng
}
}
if in.CodAmount != nil {
dest.Codamount = *in.CodAmount
}
}
func cxFirstPin(in *cxDetailsInput) *[2]float64 {
if in == nil || in.Pin == nil {
return nil
}
if in.Pin.Lat == 0 && in.Pin.Lng == 0 {
return nil
}
return &[2]float64{in.Pin.Lat, in.Pin.Lng}
}
// cxDestinationAddress builds the flat address string the rest of the system
// stores, from whatever the customer supplied. It always resolves to something
// non-empty — pickupbookings.deliveryaddress is NOT NULL, and a booking with
// only a state and a district is a legitimate booking.
func cxDestinationAddress(in *cxDetailsInput, district models.ServiceableDistrict) string {
parts := []string{}
if in != nil {
if in.Building != nil {
parts = append(parts, *in.Building)
}
if in.Street != nil {
parts = append(parts, *in.Street)
}
if in.Landmark != nil {
parts = append(parts, *in.Landmark)
}
}
parts = append(parts, district.Districtname)
address := joinNonEmpty(", ", parts...)
if address == "" {
return district.Districtcode
}
return address
}
func cxDestinationAddressFromRow(d *models.BookingDestination) string {
address := joinNonEmpty(", ", d.Building, d.Street, d.Landmark, d.Districtname)
if address == "" {
return d.Districtcode
}
return address
}
// cxStateNames resolves display names for the states a set of districts belong
// to, in one query.
func cxStateNames(districts []models.ServiceableDistrict) (map[string]string, error) {
codes := make([]string, 0, len(districts))
seen := map[string]bool{}
for _, d := range districts {
if d.Statecode != "" && !seen[d.Statecode] {
seen[d.Statecode] = true
codes = append(codes, d.Statecode)
}
}
names := make(map[string]string, len(codes))
if len(codes) == 0 {
return names, nil
}
var states []models.ServiceableState
if err := db.DB.Where("statecode IN ?", codes).Find(&states).Error; err != nil {
return names, err
}
for _, s := range states {
names[s.Statecode] = s.Statename
}
return names, nil
}
// cxToDBTime converts an IST wall clock into the shape this database stores —
// the same digits, tagged UTC so the driver writes them verbatim. See
// utils.DBNow: comparing a container's UTC clock against IST-stamped rows is
// what made date-range reports undercount.
func cxToDBTime(t time.Time) time.Time {
ist := t.In(utils.ISTLocation())
return time.Date(ist.Year(), ist.Month(), ist.Day(), ist.Hour(), ist.Minute(), ist.Second(), 0, time.UTC)
}
// ── Events ───────────────────────────────────────────────────────────────────
// publishCxBookingCreated and publishCxBookingCancelled mirror the existing
// booking events onto NATS. Best-effort and nil-checked, like every other
// publish in this codebase: the event bus is never allowed to fail a booking.
func publishCxBookingCreated(b *models.PickupBooking, destinationCount int) {
if db.Js == nil {
return
}
payload := map[string]interface{}{
"booking_id": b.Bookingid,
"booking_no": b.Bookingno,
"customer_id": b.Appcustomerid,
"pickup_address": b.Pickupaddress,
"pickup_pincode": b.Pickuppincode,
"destination_count": destinationCount,
"slot_id": b.Slotid,
"status": constants.BookingPendingPickup,
"created_at": utils.EpochMillis(b.Createdat),
}
data, err := json.Marshal(payload)
if err != nil {
return
}
if _, err := db.Js.Publish("api.v1.bookings.create", data); err != nil {
utils.Warn("Failed to publish booking.create to NATS", "booking_id", b.Bookingid, "error", err)
}
}
func publishCxBookingCancelled(b *models.PickupBooking, reason string) {
if db.Js == nil {
return
}
payload := map[string]interface{}{
"booking_id": b.Bookingid,
"booking_no": b.Bookingno,
"customer_id": b.Appcustomerid,
"status": "Cancelled",
"reason": reason,
"cancelled_at": time.Now().UnixMilli(),
}
data, err := json.Marshal(payload)
if err != nil {
return
}
if _, err := db.Js.Publish("api.v1.bookings.cancel", data); err != nil {
utils.Warn("Failed to publish booking.cancel to NATS", "booking_id", b.Bookingid, "error", err)
}
}