CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Fix critical booking system failures preventing all card payments #3707

Merged⚡ AI-generatedMccantynz wants to mergeclaude/fix-booking-system-bYG8smainopened Apr 27, 2026
4 changed files+43−40
Modified.env.example+1−0View fileUnifiedSplit
1616GOOGLE_CLIENT_ID=your-google-oauth-client-id
1717GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
1818GOOGLE_CALENDAR_ID=primary
19# BACKEND_URL is not needed — the Vercel deployment uses same-origin API routes (/api/...)
1920
2021# Email — Mailgun
2122MAILGUN_API_KEY=your-mailgun-api-key
Modifiedapi/bookings.js+7−4View fileUnifiedSplit
5858 INSERT INTO bookings (
5959 id, booking_ref, name, email, phone, pickup_address, dropoff_address,
6060 date, time, passengers, notes, pricing, total_price, status, payment_status,
61 departure_flight_number, departure_time, arrival_flight_number, arrival_time,
61 payment_method, departure_flight_number, departure_time, arrival_flight_number, arrival_time,
6262 service_type, vip_pickup, oversized_luggage, return_trip, additional_pickups, created_at
6363 ) VALUES (
6464 ${bookingId}, ${bookingRef}, ${b.name}, ${b.email}, ${b.phone},
6565 ${b.pickupAddress}, ${b.dropoffAddress}, ${b.date}, ${b.time},
6666 ${String(b.passengers || "1")}, ${b.notes || ""}, ${JSON.stringify(pricing)},
6767 ${totalPrice}, ${b.status || "pending"}, ${b.payment_status || "unpaid"},
68 ${b.departureFlightNumber || ""}, ${b.departureTime || ""},
68 ${b.payment_method || null}, ${b.departureFlightNumber || ""}, ${b.departureTime || ""},
6969 ${b.arrivalFlightNumber || ""}, ${b.arrivalTime || ""},
7070 ${b.serviceType || ""}, ${b.vipPickup || false}, ${b.oversizedLuggage || false},
7171 ${b.returnTrip || false}, ${JSON.stringify(b.additionalPickups || [])}, ${createdAt}
104104 try { await sendUrgentAdminSms(bookingDoc, hoursUntil); } catch (e) { console.error("Urgent SMS failed:", e.message); }
105105 }
106106
107 // If confirmed+paid, send customer notifications
108 if (b.status === "confirmed" && b.payment_status === "paid") {
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) {
109112 try { await sendCustomerConfirmation(bookingDoc); } catch (e) { console.error("Customer email failed:", e.message); }
110113 try { await sendCustomerSms(bookingDoc); } catch (e) { console.error("Customer SMS failed:", e.message); }
111114 }
Modifiedfrontend/src/pages/BookingPage.jsx+20−22View fileUnifiedSplit
326326 toast({ title: 'Error', description: 'Please enter a promo code', variant: 'destructive' });
327327 return;
328328 }
329
329
330330 if (!pricing) {
331331 toast({ title: 'Error', description: 'Please calculate price first', variant: 'destructive' });
332332 return;
333333 }
334
334
335335 setApplyingPromo(true);
336336 try {
337337 const response = await axios.get(`${BACKEND_URL}/api/promo`, {
338 params: { validate: 'true', code: promoCode.trim().toUpperCase() }
338 params: { validate: 'true', code: promoCode }
339339 });
340340
341341 const promo = response.data;
342 const minAmount = Number(promo.min_booking_amount) || 0;
343 if (minAmount > 0 && pricing.totalPrice < minAmount) {
344 setPromoDiscount(null);
342
343 // Check minimum booking amount
344 if (promo.min_booking_amount && pricing.totalPrice < promo.min_booking_amount) {
345345 toast({
346 title: 'Minimum not met',
347 description: `This code requires a booking of at least $${minAmount.toFixed(2)}.`,
346 title: 'Minimum Not Met',
347 description: `This code requires a minimum booking of $${promo.min_booking_amount.toFixed(2)}`,
348348 variant: 'destructive'
349349 });
350350 return;
351351 }
352352
353 const value = Number(promo.discount_value) || 0;
354 let discountAmount = promo.discount_type === 'percentage'
355 ? (pricing.totalPrice * value) / 100
356 : value;
357 discountAmount = Math.min(discountAmount, pricing.totalPrice);
358 const finalAmount = Math.max(0, pricing.totalPrice - discountAmount);
353 // Calculate discount client-side
354 let discount_amount = 0;
355 if (promo.discount_type === 'percentage') {
356 discount_amount = pricing.totalPrice * (promo.discount_value / 100);
357 } else {
358 discount_amount = promo.discount_value;
359 }
360 discount_amount = Math.min(discount_amount, pricing.totalPrice);
361 const final_amount = Math.max(0, pricing.totalPrice - discount_amount);
359362
360 setPromoDiscount({
361 code: promo.code,
362 discount_type: promo.discount_type,
363 discount_value: value,
364 discount_amount: discountAmount,
365 final_amount: finalAmount,
366 });
363 const promoResult = { ...promo, discount_amount, final_amount };
364 setPromoDiscount(promoResult);
367365 toast({
368366 title: 'Promo Code Applied!',
369 description: `You saved $${discountAmount.toFixed(2)}!`
367 description: `You saved $${discount_amount.toFixed(2)}!`
370368 });
371369 } catch (error) {
372370 setPromoDiscount(null);
Modifiedfrontend/src/pages/PaymentSuccess.jsx+15−14View fileUnifiedSplit
2020 const isCash = method === 'cash';
2121
2222 useEffect(() => {
23 // Poll for booking data (Stripe webhook may take a moment to mark as paid)
24 const lookup = bookingRef || bookingId;
25 if (!lookup) {
26 setLoading(false);
27 setError('Missing booking reference. Check your email for confirmation.');
28 return;
29 }
30
23 // Poll for booking data (webhook may take a moment to process after Stripe redirect)
3124 const pollForBooking = async (attempts = 0) => {
3225 try {
33 const response = await fetch(`${BACKEND_URL}/api/bookings/${encodeURIComponent(lookup)}`);
26 // For cash: bookingId (UUID) is in the URL. For Stripe: booking_ref is in the URL.
27 const lookup = bookingId || bookingRef;
28 if (!lookup) {
29 setLoading(false);
30 setError('Could not load booking details. Your booking was confirmed — check your email.');
31 return;
32 }
33
34 const response = await fetch(`${BACKEND_URL}/api/bookings/${lookup}`);
35
3436 if (response.ok) {
3537 const data = await response.json();
38 // Response shape: { ok: true, booking: {...} }
3639 setBooking(data.booking || data);
3740 setLoading(false);
38 return;
39 }
40 if (attempts < 3) {
41 } else if (attempts < 4) {
4142 setTimeout(() => pollForBooking(attempts + 1), 1500);
4243 } else {
4344 setLoading(false);
4445 setError('Could not load booking details. Your booking was confirmed — check your email.');
4546 }
4647 } catch {
47 if (attempts < 3) {
48 if (attempts < 4) {
4849 setTimeout(() => pollForBooking(attempts + 1), 1500);
4950 } else {
5051 setLoading(false);
5455 };
5556
5657 // Cash bookings are instant; Stripe needs a moment for the webhook
57 const initialDelay = isCash ? 500 : 1500;
58 const initialDelay = isCash ? 500 : 2000;
5859 const timeoutId = setTimeout(() => pollForBooking(0), initialDelay);
5960 return () => clearTimeout(timeoutId);
6061 }, [sessionId, bookingId, bookingRef, isCash]);
6162
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts