Fix critical booking flow bugs across all layers #3704
12 changed files+138−61
Modifiedapi/bookings.js+57−14View fileUnifiedSplit
@@ -6,7 +6,7 @@ const { getDb } = require("./lib/db");
66const { authenticateRequest } = require("./lib/auth");
77const { sendCustomerConfirmation, sendAdminNotification } = require("./lib/email");
88const { sendCustomerSms, sendAdminSmsNotification, sendUrgentAdminSms } = require("./lib/sms");
9const { isUrgentBooking } = require("./lib/pricing");
9const { calculateDistance, calculatePrice, isUrgentBooking } = require("./lib/pricing");
1010const {
1111 ok, created, badRequest, unauthorized, serverError, methodNotAllowed,
1212 uuid, isValidEmail, isValidPhone, rowToBooking, escapeHtml,
@@ -40,6 +40,53 @@ async function createBooking(req, res) {
4040 const sql = getDb();
4141 const bookingId = uuid();
4242
43 // Server-side price calculation — never trust client-supplied totals
44 const distance = await calculateDistance(b.pickupAddress, b.dropoffAddress);
45 if (distance === null) {
46 return serverError(res, "Could not calculate distance for this route — price cannot be verified. Please try again.");
47 }
48
49 const passengers = Math.max(1, Math.min(parseInt(b.passengers, 10) || 1, 20));
50 const vipPickup = b.vipPickup === true;
51 const oversizedLuggage = b.oversizedLuggage === true;
52 const returnTrip = b.returnTrip === true;
53
54 const pricingResult = calculatePrice(distance, passengers, vipPickup, oversizedLuggage);
55 let totalPrice = returnTrip ? pricingResult.totalPrice * 2 : pricingResult.totalPrice;
56
57 // Validate and apply promo code server-side if provided
58 let appliedPromoCode = null;
59 if (b.promoCode) {
60 const promoRows = await sql`
61 SELECT * FROM promo_codes
62 WHERE code = ${b.promoCode.toUpperCase()}
63 AND active = true
64 AND (expiry_date IS NULL OR expiry_date > NOW())
65 AND (max_uses IS NULL OR uses_count < max_uses)
66 `;
67 if (promoRows.length > 0) {
68 const promo = promoRows[0];
69 const minAmount = parseFloat(promo.min_booking_amount) || 0;
70 if (totalPrice >= minAmount) {
71 const discountValue = parseFloat(promo.discount_value);
72 const discount = promo.discount_type === "percentage"
73 ? totalPrice * (discountValue / 100)
74 : discountValue;
75 totalPrice = Math.max(0, totalPrice - Math.min(discount, totalPrice));
76 appliedPromoCode = promo.code;
77 await sql`UPDATE promo_codes SET uses_count = uses_count + 1 WHERE id = ${promo.id}`;
78 }
79 }
80 }
81
82 totalPrice = Math.round(totalPrice * 100) / 100;
83 const pricing = {
84 ...pricingResult,
85 returnTrip,
86 appliedPromoCode,
87 totalPrice,
88 };
89
4390 // Generate booking reference (atomic — uses DB)
4491 const lastRef = await sql`
4592 SELECT booking_ref FROM bookings
@@ -50,9 +97,7 @@ async function createBooking(req, res) {
5097 const nextNum = lastRef.length > 0 ? parseInt(lastRef[0].booking_ref.slice(1), 10) + 1 : 1;
5198 const bookingRef = `H${nextNum}`;
5299
53 const totalPrice = b.totalPrice != null ? b.totalPrice : (b.pricing?.totalPrice || 0);
54100 const createdAt = new Date().toISOString();
55 const pricing = b.pricing || {};
56101
57102 await sql`
58103 INSERT INTO bookings (
@@ -63,8 +108,8 @@ async function createBooking(req, res) {
63108 ) VALUES (
64109 ${bookingId}, ${bookingRef}, ${b.name}, ${b.email}, ${b.phone},
65110 ${b.pickupAddress}, ${b.dropoffAddress}, ${b.date}, ${b.time},
66 ${String(b.passengers || "1")}, ${b.notes || ""}, ${JSON.stringify(pricing)},
67 ${totalPrice}, ${b.status || "pending"}, ${b.payment_status || "unpaid"},
111 ${String(passengers)}, ${b.notes || ""}, ${JSON.stringify(pricing)},
112 ${totalPrice}, ${"pending"}, ${b.payment_method === 'cash' ? 'pay_on_day' : 'unpaid'},
68113 ${b.payment_method || null}, ${b.departureFlightNumber || ""}, ${b.departureTime || ""},
69114 ${b.arrivalFlightNumber || ""}, ${b.arrivalTime || ""},
70115 ${b.serviceType || ""}, ${b.vipPickup || false}, ${b.oversizedLuggage || false},
@@ -82,12 +127,12 @@ async function createBooking(req, res) {
82127 dropoffAddress: b.dropoffAddress,
83128 date: b.date,
84129 time: b.time,
85 passengers: String(b.passengers || "1"),
130 passengers: String(passengers),
86131 notes: b.notes || "",
87132 pricing,
88133 totalPrice,
89 status: b.status || "pending",
90 payment_status: b.payment_status || "unpaid",
134 status: "pending",
135 payment_status: b.payment_method === 'cash' ? 'pay_on_day' : "unpaid",
91136 departureFlightNumber: b.departureFlightNumber || "",
92137 departureTime: b.departureTime || "",
93138 arrivalFlightNumber: b.arrivalFlightNumber || "",
@@ -104,11 +149,9 @@ async function createBooking(req, res) {
104149 try { await sendUrgentAdminSms(bookingDoc, hoursUntil); } catch (e) { console.error("Urgent SMS failed:", e.message); }
105150 }
106151
107 // Send customer notifications for cash bookings (pay on day) and for
108 // any booking that is already confirmed+paid at creation time (e.g. admin-created)
109 const isCash = b.payment_status === "pay_on_day";
110 const isAlreadyPaid = b.status === "confirmed" && b.payment_status === "paid";
111 if (isCash || isAlreadyPaid) {
152 // Send customer confirmation for cash bookings only (Stripe bookings get confirmed via webhook)
153 const isCash = b.payment_method === "cash";
154 if (isCash) {
112155 try { await sendCustomerConfirmation(bookingDoc); } catch (e) { console.error("Customer email failed:", e.message); }
113156 try { await sendCustomerSms(bookingDoc); } catch (e) { console.error("Customer SMS failed:", e.message); }
114157 }
@@ -117,7 +160,7 @@ async function createBooking(req, res) {
117160 message: "Booking created successfully",
118161 booking_id: bookingId,
119162 booking_ref: bookingRef,
120 status: b.status || "pending",
163 status: "pending",
121164 });
122165 } catch (err) {
123166 return serverError(res, err.message);
Modifiedapi/bookings/[id]/cancel.js+6−2View fileUnifiedSplit
@@ -1,13 +1,17 @@
1// POST /api/bookings/:id/cancel — Cancel a booking
1// POST /api/bookings/:id/cancel — Cancel a booking (admin only)
22
33const { getDb } = require("../../lib/db");
4const { authenticateRequest } = require("../../lib/auth");
45const { sendCancellationEmail } = require("../../lib/email");
56const { sendCancellationSms } = require("../../lib/sms");
6const { ok, notFound, serverError, methodNotAllowed, rowToBooking } = require("../../lib/helpers");
7const { ok, notFound, unauthorized, serverError, methodNotAllowed, rowToBooking } = require("../../lib/helpers");
78
89module.exports = async function handler(req, res) {
910 if (req.method !== "POST") return methodNotAllowed(res, ["POST"]);
1011
12 const user = authenticateRequest(req);
13 if (!user) return unauthorized(res);
14
1115 const { id } = req.query;
1216
1317 try {
Modifiedapi/bookings/[id]/resend-email.js+2−1View fileUnifiedSplit
@@ -25,7 +25,8 @@ module.exports = async function handler(req, res) {
2525 }
2626 }
2727
28 await sendCustomerConfirmation(booking);
28 const sent = await sendCustomerConfirmation(booking);
29 if (!sent) return serverError(res, "Failed to send email — check Mailgun configuration");
2930
3031 await sql`UPDATE bookings SET last_email_sent = ${new Date().toISOString()} WHERE id = ${id}`;
3132
Modifiedapi/bookings/[id]/resend-sms.js+2−1View fileUnifiedSplit
@@ -24,7 +24,8 @@ module.exports = async function handler(req, res) {
2424 }
2525 }
2626
27 await sendCustomerSms(booking);
27 const sent = await sendCustomerSms(booking);
28 if (!sent) return serverError(res, "Failed to send SMS — check Twilio configuration");
2829
2930 await sql`UPDATE bookings SET last_sms_sent = ${new Date().toISOString()} WHERE id = ${id}`;
3031
Modifiedapi/bookings/[id]/update-status.js+9−0View fileUnifiedSplit
@@ -19,6 +19,15 @@ module.exports = async function handler(req, res) {
1919 return badRequest(res, "Provide status and/or payment_status");
2020 }
2121
22 const VALID_STATUSES = ["pending", "confirmed", "completed", "cancelled", "no_show"];
23 const VALID_PAYMENT_STATUSES = ["unpaid", "paid", "pay_on_day", "refunded", "failed"];
24 if (status && !VALID_STATUSES.includes(status)) {
25 return badRequest(res, `Invalid status. Must be one of: ${VALID_STATUSES.join(", ")}`);
26 }
27 if (payment_status && !VALID_PAYMENT_STATUSES.includes(payment_status)) {
28 return badRequest(res, `Invalid payment_status. Must be one of: ${VALID_PAYMENT_STATUSES.join(", ")}`);
29 }
30
2231 try {
2332 const sql = getDb();
2433 const now = new Date().toISOString();
Modifiedapi/cron/reminders.js+16−13View fileUnifiedSplit
@@ -19,10 +19,9 @@ module.exports = async function handler(req, res) {
1919 try {
2020 const sql = getDb();
2121
22 // Calculate tomorrow's date in UTC
23 const tomorrow = new Date();
24 tomorrow.setDate(tomorrow.getDate() + 1);
25 const tomorrowStr = tomorrow.toISOString().split("T")[0]; // YYYY-MM-DD
22 // Calculate tomorrow's date in NZ timezone (Pacific/Auckland)
23 const tomorrowStr = new Intl.DateTimeFormat("en-CA", { timeZone: "Pacific/Auckland" })
24 .format(new Date(Date.now() + 86400000));
2625
2726 const rows = await sql`
2827 SELECT * FROM bookings
@@ -36,15 +35,19 @@ module.exports = async function handler(req, res) {
3635 let sentCount = 0;
3736 for (const booking of rows) {
3837 try {
39 await sendReminderEmail(booking);
40 await sendReminderSms(booking);
41
42 await sql`
43 UPDATE bookings
44 SET reminder_sent = true, reminder_sent_at = ${new Date().toISOString()}
45 WHERE id = ${booking.id}
46 `;
47 sentCount++;
38 const emailOk = await sendReminderEmail(booking);
39 const smsOk = await sendReminderSms(booking);
40
41 if (emailOk || smsOk) {
42 await sql`
43 UPDATE bookings
44 SET reminder_sent = true, reminder_sent_at = ${new Date().toISOString()}
45 WHERE id = ${booking.id}
46 `;
47 sentCount++;
48 } else {
49 console.error(`All reminders failed for ${booking.booking_ref} — will retry next run`);
50 }
4851 } catch (err) {
4952 console.error(`Reminder failed for ${booking.booking_ref}: ${err.message}`);
5053 }
Modifiedapi/lib/email.js+4−4View fileUnifiedSplit
@@ -122,7 +122,7 @@ function sendCustomerConfirmation(booking) {
122122 </table>
123123 </div>
124124 <div style="text-align: center; background: #f8fafc; border-radius: 12px; padding: 30px; margin: 30px 0;">
125 <p style="margin: 10px 0; color: #6b7280;"><strong>Email:</strong> <span style="color: #f59e0b;">bookings@bookaride.co.nz</span></p>
125 <p style="margin: 10px 0; color: #6b7280;"><strong>Email:</strong> <span style="color: #f59e0b;">info@bookaride.co.nz</span></p>
126126 <p style="margin: 10px 0; color: #6b7280;"><strong>Phone:</strong> <span style="color: #f59e0b;">021 743 321</span></p>
127127 <p style="margin: 10px 0; color: #6b7280;"><strong>Track:</strong> <span style="color: #f59e0b;">${escapeHtml(frontendUrl)}/tracking/${ref}</span></p>
128128 </div>
@@ -249,8 +249,8 @@ function sendPasswordResetEmail(email, resetToken) {
249249function sendReminderEmail(booking) {
250250 const ref = escapeHtml(booking.booking_ref);
251251 const name = escapeHtml(booking.name);
252 const pickup = escapeHtml(booking.pickup_address);
253 const dropoff = escapeHtml(booking.dropoff_address);
252 const pickup = escapeHtml(booking.pickupAddress || booking.pickup_address || "");
253 const dropoff = escapeHtml(booking.dropoffAddress || booking.dropoff_address || "");
254254 const formattedDate = formatDateWithDay(booking.date);
255255 const time = escapeHtml(booking.time);
256256
@@ -271,7 +271,7 @@ function sendReminderEmail(booking) {
271271 <p><strong>Pickup:</strong> ${pickup}</p>
272272 <p><strong>Drop-off:</strong> ${dropoff}</p>
273273 </div>
274 <p>Questions? Contact us at 021 743 321 or bookings@bookaride.co.nz</p>
274 <p>Questions? Contact us at 021 743 321 or info@bookaride.co.nz</p>
275275 </div>
276276 </div>`;
277277
Modifiedapi/lib/sms.js+7−2View fileUnifiedSplit
@@ -75,7 +75,11 @@ function sendAdminSmsNotification(booking) {
7575 const formattedDate = formatDateNz(booking.date);
7676 const total = booking.pricing?.totalPrice || booking.totalPrice || 0;
7777
78 const message = `NEW BOOKING!\n\nRef: ${ref}\n${booking.name}\n${formattedDate} at ${booking.time}\n${booking.passengers} pax | $${Number(total).toFixed(2)}\n\nFrom: ${(booking.pickupAddress || "").slice(0, 40)}...\nTo: ${(booking.dropoffAddress || "").slice(0, 40)}...\n\nLogin to admin to manage.`;
78 const pickupRaw = booking.pickupAddress || booking.pickup_address || "";
79 const dropoffRaw = booking.dropoffAddress || booking.dropoff_address || "";
80 const pickupStr = pickupRaw.length > 40 ? pickupRaw.slice(0, 40) + "..." : pickupRaw;
81 const dropoffStr = dropoffRaw.length > 40 ? dropoffRaw.slice(0, 40) + "..." : dropoffRaw;
82 const message = `NEW BOOKING!\n\nRef: ${ref}\n${booking.name}\n${formattedDate} at ${booking.time}\n${booking.passengers} pax | $${Number(total).toFixed(2)}\n\nFrom: ${pickupStr}\nTo: ${dropoffStr}\n\nLogin to admin to manage.`;
7983
8084 return sendSms(adminPhone, message);
8185}
@@ -99,7 +103,8 @@ function sendReminderSms(booking) {
99103 const ref = booking.booking_ref;
100104 const formattedDate = formatDateNz(booking.date);
101105
102 const message = `REMINDER: Your airport transfer is tomorrow!\nRef: ${ref}\nPickup: ${formattedDate} at ${booking.time}\nFrom: ${(booking.pickup_address || "").slice(0, 50)}\nBe ready 5-10 mins early. Questions? 021 743 321`;
106 const pickup = booking.pickupAddress || booking.pickup_address || "";
107 const message = `REMINDER: Your airport transfer is tomorrow!\nRef: ${ref}\nPickup: ${formattedDate} at ${booking.time}\nFrom: ${pickup.slice(0, 50)}\nBe ready 5-10 mins early. Questions? 021 743 321`;
103108
104109 return sendSms(booking.phone, message);
105110}
Modifiedapi/stripe/webhook.js+8−7View fileUnifiedSplit
@@ -29,15 +29,15 @@ module.exports = async function handler(req, res) {
2929 }
3030 const rawBody = Buffer.concat(chunks);
3131
32 if (!webhookSecret) {
33 console.error("STRIPE_WEBHOOK_SECRET not configured — rejecting webhook");
34 return res.status(500).json({ ok: false, error: "Webhook not configured" });
35 }
36
3237 let event;
3338 try {
34 if (webhookSecret) {
35 const sig = req.headers["stripe-signature"];
36 event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret);
37 } else {
38 event = JSON.parse(rawBody.toString());
39 console.warn("STRIPE_WEBHOOK_SECRET not set — webhook signature not verified");
40 }
39 const sig = req.headers["stripe-signature"];
40 event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret);
4141 } catch (err) {
4242 console.error("Webhook signature verification failed:", err.message);
4343 return res.status(400).json({ ok: false, error: "Invalid signature" });
@@ -88,6 +88,7 @@ module.exports = async function handler(req, res) {
8888 console.log(`Payment confirmed for booking ${booking.booking_ref}`);
8989 } catch (err) {
9090 console.error("Webhook processing error:", err.message);
91 return res.status(500).json({ ok: false, error: "Processing failed — Stripe will retry" });
9192 }
9293 }
9394
Modifiedfrontend/src/App.js+1−1View fileUnifiedSplit
@@ -297,7 +297,7 @@ function PublicRoutes() {
297297 <Route path="/driver/job/:id" element={<DriverJobResponse />} />
298298 <Route path="/driver-tracking" element={<DriverTracking />} />
299299 <Route path="/customer-tracking" element={<CustomerTracking />} />
300 <Route path="/tracking/:ref" element={<CustomerTracking />} />
300 <Route path="/tracking/:trackingRef" element={<CustomerTracking />} />
301301 <Route path="/flight-tracker" element={<FlightTracker />} />
302302
303303 {/* Catch-all: redirect unknown routes to home */}
Modifiedfrontend/src/pages/BookingPage.jsx+23−13View fileUnifiedSplit
@@ -7,6 +7,9 @@ import Footer from '../components/Footer';
77import { Loader2, MapPin, Calendar, Clock, Users, Mail, Phone, User, FileText, Globe, DollarSign, Plus, X, ChevronLeft, ChevronRight } from 'lucide-react';
88import { useToast } from '../hooks/use-toast';
99import axios from 'axios';
10import { useGoogleMaps, attachAutocomplete } from '../hooks/useGoogleMaps';
11import PageMeta from '../components/PageMeta';
12import { BACKEND_URL, GOOGLE_MAPS_API_KEY } from '../config';
1013
1114// Constants
1215const VIP_PICKUP_FEE = 15;
@@ -17,11 +20,6 @@ const PRICE_DEBOUNCE_MS = 500;
1720// Safety: prevent hung requests (no UI change)
1821axios.defaults.timeout = API_TIMEOUT_MS;
1922
20import { useGoogleMaps, attachAutocomplete } from '../hooks/useGoogleMaps';
21import PageMeta from '../components/PageMeta';
22
23import { BACKEND_URL, GOOGLE_MAPS_API_KEY } from '../config';
24
2523// Compact Date Picker Modal - Clean iOS-style
2624const DatePickerModal = ({ isOpen, onClose, onSelect, selectedDate }) => {
2725 const [currentMonth, setCurrentMonth] = useState(new Date());
@@ -286,7 +284,7 @@ const BookingPage = () => {
286284 });
287285 };
288286
289 // Debounced price recalculation when address or passenger fields change
287 // Debounced price recalculation when address, passenger, or add-on fields change
290288 useEffect(() => {
291289 if (formData.pickupAddress && formData.dropoffAddress) {
292290 const timer = setTimeout(() => {
@@ -294,7 +292,7 @@ const BookingPage = () => {
294292 }, PRICE_DEBOUNCE_MS);
295293 return () => clearTimeout(timer);
296294 }
297 }, [formData.pickupAddress, formData.dropoffAddress, formData.passengers]);
295 }, [formData.pickupAddress, formData.dropoffAddress, formData.passengers, formData.vipPickup, formData.oversizedLuggage, formData.returnTrip]);
298296
299297 const addPickupLocation = () => {
300298 setFormData({
@@ -475,11 +473,18 @@ const BookingPage = () => {
475473 const handleSubmit = async (e) => {
476474 e.preventDefault();
477475
478 // Validate required fields that aren't covered by native `required`
476 if (!agreedTerms) {
477 toast({ title: "Terms Required", description: "Please agree to the terms and conditions", variant: "destructive" });
478 return;
479 }
479480 if (!formData.serviceType) {
480481 toast({ title: "Service Type Required", description: "Please select a service type", variant: "destructive" });
481482 return;
482483 }
484 if (!formData.pickupAddress || !formData.dropoffAddress) {
485 toast({ title: "Addresses Required", description: "Please enter pickup and drop-off addresses", variant: "destructive" });
486 return;
487 }
483488 if (!formData.date) {
484489 toast({ title: "Date Required", description: "Please select a pickup date", variant: "destructive" });
485490 return;
@@ -501,6 +506,10 @@ const BookingPage = () => {
501506 return;
502507 }
503508
509 // Use discounted price if a promo code was applied
510 const finalPrice = promoDiscount ? promoDiscount.final_amount : pricing.totalPrice;
511 const finalPricing = { ...pricing, totalPrice: finalPrice };
512
504513 setSubmitting(true);
505514
506515 try {
@@ -508,9 +517,10 @@ const BookingPage = () => {
508517 const bookingResponse = await axios.post(`${BACKEND_URL}/api/bookings`, {
509518 ...formData,
510519 passengers: String(formData.passengers),
511 pricing: pricing,
520 pricing: finalPricing,
521 totalPrice: finalPrice,
522 promoCode: promoDiscount ? promoDiscount.code : undefined,
512523 payment_method: formData.paymentMethod === 'cash' ? 'cash' : 'stripe',
513 payment_status: formData.paymentMethod === 'cash' ? 'pay_on_day' : 'pending'
514524 });
515525
516526 const newBookingId = bookingResponse.data.booking_id;
@@ -645,7 +655,7 @@ const BookingPage = () => {
645655 ref={pickupInputRef}
646656 type="text"
647657 name="pickupAddress"
648 defaultValue={formData.pickupAddress}
658 value={formData.pickupAddress}
649659 onChange={handleChange}
650660 placeholder="Enter pickup address..."
651661 required
@@ -695,7 +705,7 @@ const BookingPage = () => {
695705 ref={dropoffInputRef}
696706 type="text"
697707 name="dropoffAddress"
698 defaultValue={formData.dropoffAddress}
708 value={formData.dropoffAddress}
699709 onChange={handleChange}
700710 placeholder="Enter drop-off address..."
701711 required
@@ -927,7 +937,7 @@ const BookingPage = () => {
927937 name="phone"
928938 value={formData.phone}
929939 onChange={handleChange}
930 placeholder="021 123 4567"
940 placeholder="e.g. 021 555 0000"
931941 required
932942 className="h-11 rounded-md"
933943 />
Modifiedfrontend/src/pages/MyBooking.jsx+3−3View fileUnifiedSplit
@@ -175,14 +175,14 @@ const MyBooking = () => {
175175 <MapPin className="w-5 h-5 text-gold mt-0.5 flex-shrink-0" />
176176 <div>
177177 <p className="text-gray-400 text-xs uppercase tracking-wider">Pickup</p>
178 <p className="text-white">{booking.pickupAddress}</p>
178 <p className="text-white">{booking.pickupAddress || booking.pickup_address}</p>
179179 </div>
180180 </div>
181181 <div className="flex items-start gap-3">
182182 <MapPin className="w-5 h-5 text-gold mt-0.5 flex-shrink-0" />
183183 <div>
184184 <p className="text-gray-400 text-xs uppercase tracking-wider">Drop-off</p>
185 <p className="text-white">{booking.dropoffAddress}</p>
185 <p className="text-white">{booking.dropoffAddress || booking.dropoff_address}</p>
186186 </div>
187187 </div>
188188 <div className="grid grid-cols-2 sm:grid-cols-3 gap-4 pt-2">
@@ -213,7 +213,7 @@ const MyBooking = () => {
213213 {/* Price */}
214214 <div className="flex items-center justify-between py-4 border-t border-b border-gold/20 mb-6">
215215 <span className="text-gray-300 font-medium">Total Price</span>
216 <span className="text-gold text-2xl font-bold">${Number(booking.totalPrice).toFixed(2)} NZD</span>
216 <span className="text-gold text-2xl font-bold">${Number(booking.totalPrice || booking.total_price || 0).toFixed(2)} NZD</span>
217217 </div>
218218
219219 {/* Confirmation Info */}
220220
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts