import { requireAuth } from "@/lib/jwt"; import { transaction } from "@/lib/db"; import { getOrder, consumeOrderForRide } from "@/lib/payment-orders"; import { sendPushToDriver } from "@/lib/push"; import { DRIVER_BUSY_ARRAY, generatePickupCode } from "@/lib/ride-lifecycle"; // POST — the rider picks one of the drivers who offered, and pays. // // { offer_id, payment_method: 'cash' } // { offer_id, payment_method: 'card', payment_order_id } // // This is the single moment a ride is assigned. Everything that has to be true // at once — the request is still open, this offer is still live, the driver is // still free, and (for card) a paid order of the right amount exists and has // not been spent — is checked inside one transaction, so a rider and a // disappearing driver can't half-complete it. // // The card order is consumed here rather than earlier for the same reason: if // the pick fails because the driver just took another job, the transaction // rolls back with the order still 'paid', and the rider can pick a different // driver with the money they already put down instead of paying twice. export async function POST(req: Request, { id }: { id: string }) { const rideId = Number(id); if (!Number.isInteger(rideId)) { return Response.json({ error: "Invalid ride id." }, { status: 400 }); } const auth = requireAuth(req); if ("error" in auth) return auth.error; let body: { offer_id?: number; payment_method?: string; payment_order_id?: string; }; try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON body." }, { status: 400 }); } const offerId = Number(body.offer_id); if (!Number.isInteger(offerId)) { return Response.json({ error: "offer_id is required." }, { status: 400 }); } const method = body.payment_method; if (method !== "cash" && method !== "card") { return Response.json({ error: "Invalid payment method." }, { status: 400 }); } try { // Card: everything about the order is verified before the transaction // opens, so the only thing left to do inside it is spend it. if (method === "card") { if (!body.payment_order_id) { return Response.json( { error: "Missing payment order id." }, { status: 400 }, ); } const order = await getOrder(body.payment_order_id); if (!order) return Response.json( { error: "Payment order not found." }, { status: 404 }, ); if (order.user_id !== auth.userId) return Response.json({ error: "Unauthorized." }, { status: 403 }); if (order.status !== "paid") return Response.json( { error: "Payment not verified." }, { status: 400 }, ); } const picked = await transaction< | { driverId: number; fare: number } | "gone" | "amount_mismatch" | "order_spent" >(async (tx) => { // Lock the request. A second tap on a second driver serialises behind // this and finds the ride already assigned. const rides = await tx<{ status: string; fare_price: number; origin_address: string; }>` SELECT status, fare_price, origin_address FROM rides WHERE ride_id = ${rideId} AND user_id = ${auth.userId} FOR UPDATE `; const ride = rides[0]; if (!ride || ride.status !== "requested") return "gone"; const offers = await tx<{ driver_id: number }>` SELECT driver_id FROM ride_offers WHERE id = ${offerId} AND ride_id = ${rideId} AND status = 'offered' `; const offer = offers[0]; if (!offer) return "gone"; // The driver may have been picked by somebody else in the seconds the // rider spent deciding. Their other ride is the authority, not the offer. const busy = await tx<{ n: number }>` SELECT COUNT(*)::int AS n FROM rides WHERE driver_id = ${offer.driver_id} AND status = ANY(${DRIVER_BUSY_ARRAY}::text[]) `; if ((busy[0]?.n ?? 0) > 0) return "gone"; let paymentStatus = "cash"; let orderId: string | null = null; if (method === "card") { const order = await getOrder(body.payment_order_id!); if (!order) return "gone"; // Re-checked against the row we just locked: the fare is authoritative // here, not the number the client did its arithmetic with. if (order.amount_cents !== Number(ride.fare_price)) return "amount_mismatch"; const consumed = await consumeOrderForRide( body.payment_order_id!, auth.userId, tx, ); if (!consumed) return "order_spent"; paymentStatus = "paid"; orderId = body.payment_order_id!; } // Assign. The status='requested' guard is what stops a double-submit // from reassigning a ride that already has a driver. const assigned = await tx<{ ride_id: number }>` UPDATE rides SET status = 'accepted', driver_id = ${offer.driver_id}, accepted_at = CURRENT_TIMESTAMP, payment_status = ${paymentStatus}, payment_order_id = COALESCE(${orderId}, payment_order_id), pickup_code = COALESCE(pickup_code, ${generatePickupCode()}) WHERE ride_id = ${rideId} AND status = 'requested' RETURNING ride_id `; if (!assigned[0]) return "gone"; await tx` UPDATE ride_offers SET status = 'accepted', responded_at = CURRENT_TIMESTAMP WHERE id = ${offerId} `; // Everyone else who volunteered is released in the same breath, so no // driver is left with a card for a job that is already someone else's. await tx` UPDATE ride_offers SET status = 'passed', responded_at = CURRENT_TIMESTAMP WHERE ride_id = ${rideId} AND id <> ${offerId} AND status = 'offered' `; return { driverId: offer.driver_id, fare: Number(ride.fare_price) }; }); if (picked === "amount_mismatch") { return Response.json( { error: "Payment does not match this ride." }, { status: 400 }, ); } if (picked === "order_spent") { return Response.json( { error: "That payment has already been used." }, { status: 409 }, ); } if (picked === "gone") { return Response.json( { error: "That driver is no longer available.", code: "OFFER_UNAVAILABLE", }, { status: 409 }, ); } void sendPushToDriver(picked.driverId, { title: "You got the ride", body: "The rider picked you. Head to the pickup point.", data: { type: "ride_assigned", rideId }, }); return Response.json({ data: { status: "accepted" } }); } catch (error) { console.error("[RIDE_SELECT]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } }