src/Controller/TaxiController.php line 140
<?phpnamespace App\Controller;use App\Entity\Booking;use App\Entity\Driver;use App\Entity\Location;use App\Entity\PaymentCard;use App\Entity\TourReservation;use App\Entity\TourRoute;use App\Entity\Tuk;use App\Entity\User;use App\Repository\BookingRepository;use App\Repository\LocationRepository;use App\Repository\PaymentCardRepository;use App\Repository\TourReservationRepository;use App\Repository\TourRouteRepository;use App\Repository\TukRepository;use App\Service\Checkout\CheckoutException;use App\Service\Checkout\GuestAccountFactory;use App\Service\Checkout\GuestAccountMailer;use App\Service\Mercure\MercurePublisher;use App\Service\Pricing\TourPricing;use App\Service\Reservation\ReservationMailer;use App\Services\Routing\RoadGeometryProvider;use Doctrine\ORM\EntityManagerInterface;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Bundle\SecurityBundle\Security;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Annotation\Route;use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;use Symfony\Component\Security\Http\Attribute\IsGranted;/*** The booking API behind the Yandex-Taxi-style bottom sheet.** Flow: pick a route → the map paints its road → pick a tuk from the ones shown* → choose cash or card → order. Every response is JSON; the sheet is rendered* client-side.*/#[Route('/api', name: 'api_')]class TaxiController extends AbstractController{/*** Assumed average tuk-tuk speed through Yerevan traffic, in metres per* minute (~18 km/h). Used to turn a distance into a rough ETA.*/private const TUK_SPEED_M_PER_MIN = 300.0;public function __construct(private readonly EntityManagerInterface $em,private readonly MercurePublisher $mercure,private readonly TourPricing $pricing,) {}// ── Routes ───────────────────────────────────────────────────────────/*** Every active tour route with its ordered stops. The client feeds the* stop coordinates to Yandex's multi-router to paint the road.*/#[Route('/routes', name: 'routes', methods: ['GET'])]public function routes(TourRouteRepository $routes): JsonResponse{$out = [];foreach ($routes->findAllActiveWithPoints() as $route) {$out[] = $this->serializeRoute($route);}return $this->json(['routes' => $out,// A passenger-drawn tour has no route row to carry a table, so the// client gets the custom one here rather than re-deriving the// surcharges itself.'customPriceTable' => $this->pricing->customTable(),'maxPassengers' => TourPricing::MAX_PASSENGERS,]);}#[Route('/routes/{slug}', name: 'route_show', methods: ['GET'])]public function route(string $slug, TourRouteRepository $routes): JsonResponse{$route = $routes->findBySlugWithPoints($slug);if ($route === null) {return $this->json(['error' => 'Route not found.'], Response::HTTP_NOT_FOUND);}return $this->json(['route' => $this->serializeRoute($route)]);}// ── Tuks ─────────────────────────────────────────────────────────────/*** The tuks to draw on the map once a route is chosen.** With lat/lng we return the bookable tuks nearest that point, each with a* distance and ETA. Without them (the visitor declined GPS) we fall back to* every bookable tuk, unsorted — there is no "nearest" without a position.*/#[Route('/tuks', name: 'tuks', methods: ['GET'])]public function tuks(Request $request, TukRepository $tuks): JsonResponse{$lat = $request->query->get('lat');$lng = $request->query->get('lng');// Clamp so a caller cannot ask for the whole country.$radius = (float) $request->query->get('radius', '3000');$radius = max(100.0, min($radius, 20000.0));$out = [];if (is_numeric($lat) && is_numeric($lng)) {foreach ($tuks->findBookableNear((float) $lat, (float) $lng, $radius) as $row) {$out[] = $this->serializeTuk($row['tuk'], $row['distance']);}} else {foreach ($tuks->findAllBookable() as $tuk) {$out[] = $this->serializeTuk($tuk, null);}}return $this->json(['tuks' => $out,'count' => count($out),]);}/*** Every live tuk to paint on the browsing map — free ones and ones already* on a tour — each with its status so the client can colour them (free vs* busy). This is the ambient "see the fleet moving" view, distinct from* /tuks which returns only the bookable tuks for the selection step.*/#[Route('/tuks/map', name: 'tuks_map', methods: ['GET'])]public function tuksMap(TukRepository $tuks): JsonResponse{$out = [];foreach ($tuks->findAllLive() as $tuk) {$out[] = $this->serializeTuk($tuk, null);}return $this->json(['tuks' => $out,'count' => count($out),]);}// ── Reservations (advance bookings) ──────────────────────────────────/*** Request a tour for a future date and time.** Unlike a live booking this assigns no tuk and starts no state machine: it* records the passenger's wish, emails the back office, and waits for a human* to approve or decline it. The passenger is emailed the outcome.*/#[Route('/reservations', name: 'reservation_create', methods: ['POST'])]#[IsGranted('ROLE_USER')]public function createReservation(Request $request,TourReservationRepository $reservations,ReservationMailer $mailer,): JsonResponse {/** @var User $user */$user = $this->getUser();if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);}$body = $this->decodeBody($request);if ($body === null) {return $this->json(['error' => 'Malformed JSON body.'], Response::HTTP_BAD_REQUEST);}$route = $this->em->getRepository(TourRoute::class)->find((int) ($body['routeId'] ?? 0));if ($route === null || !$route->isActive()) {return $this->json(['error' => 'Route not found.'], Response::HTTP_NOT_FOUND);}// Parse the requested date+time. The client sends an ISO string// ("2026-07-18T10:00"); reject anything unparseable or in the past.$when = $this->parseScheduledAt($body['scheduledAt'] ?? null);if ($when === null) {return $this->json(['error' => 'Please choose a valid date and time.'], Response::HTTP_BAD_REQUEST);}if ($when < new \DateTimeImmutable('+1 hour')) {return $this->json(['error' => 'Please pick a time at least an hour from now.'], Response::HTTP_BAD_REQUEST);}$passengers = $body['passengers'] ?? 1;// A tuk seats three, so a tour party is capped at three.if (!$this->pricing->isValidPartySize($passengers)) {return $this->json(['error' => sprintf('Passengers must be between 1 and %d.', TourPricing::MAX_PASSENGERS)],Response::HTTP_BAD_REQUEST);}$passengers = (int) $passengers;$reservation = new TourReservation();$reservation->setUser($user);$reservation->setTourRoute($route);$reservation->setScheduledAt($when);$reservation->setPassengers($passengers);$reservation->setPickupAddress($this->cleanString($body['pickupAddress'] ?? null, 255));$reservation->setNote($this->cleanString($body['note'] ?? null, 500));// Snapshot the price — party size included — so neither a later route// price change nor a different head count rewrites this quote.$reservation->setPriceAmd($this->pricing->priceFor($route, $passengers));$reservations->save($reservation);// Let the back office know there is something to review (best-effort).$mailer->notifyAdmin($reservation);return $this->json(['reservation' => $this->serializeReservation($reservation)], Response::HTTP_CREATED);}/*** Request a tour the passenger put together themselves.** Same review-then-approve lifecycle as a reservation against one of our* published lines — the difference is that there is no TourRoute row behind* it, just an ordered pick of stops from our own catalogue. The stops are* chosen by id, never by coordinate: the client cannot name a place we do* not cover, and the latitude/longitude written to the reservation always* come from the Location row, not from the request body.*/#[Route('/reservations/custom', name: 'reservation_custom', methods: ['POST'])]#[IsGranted('ROLE_USER')]public function createCustomReservation(Request $request,TourReservationRepository $reservations,ReservationMailer $mailer,RoadGeometryProvider $roads,LocationRepository $locations,): JsonResponse {/** @var User $user */$user = $this->getUser();if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);}$body = $this->decodeBody($request);if ($body === null) {return $this->json(['error' => 'Malformed JSON body.'], Response::HTTP_BAD_REQUEST);}$points = $this->resolveCustomStops($body['locationIds'] ?? null, $locations);if ($points === null) {return $this->json(['error' => sprintf('Choose between %d and %d stops from the list.',TourReservation::CUSTOM_MIN_POINTS,TourReservation::CUSTOM_MAX_POINTS),],Response::HTTP_BAD_REQUEST);}$when = $this->parseScheduledAt($body['scheduledAt'] ?? null);if ($when === null) {return $this->json(['error' => 'Please choose a valid date and time.'], Response::HTTP_BAD_REQUEST);}if ($when < new \DateTimeImmutable('+1 hour')) {return $this->json(['error' => 'Please pick a time at least an hour from now.'], Response::HTTP_BAD_REQUEST);}$passengers = $body['passengers'] ?? 1;if (!$this->pricing->isValidPartySize($passengers)) {return $this->json(['error' => sprintf('Passengers must be between 1 and %d.', TourPricing::MAX_PASSENGERS)],Response::HTTP_BAD_REQUEST);}$passengers = (int) $passengers;$reservation = new TourReservation();$reservation->setKind(TourReservation::KIND_CUSTOM);$reservation->setUser($user);$reservation->setScheduledAt($when);$reservation->setPassengers($passengers);$reservation->setCustomTitle($this->cleanString($body['title'] ?? null, 120));$reservation->setCustomPoints($points);$reservation->setPickupAddress($this->cleanString($body['pickupAddress'] ?? null, 255));$reservation->setNote($this->cleanString($body['note'] ?? null, 500));$reservation->setPriceAmd($this->pricing->priceForCustom($passengers));// Snap the drawn line to real streets. Best-effort on purpose: OSRM// being unreachable must not lose the passenger's request — the map// then just joins their stops with straight lines.try {$road = $roads->fetch(array_map(static fn (array $p) => [$p['lat'], $p['lng']],$points));if ($road !== null) {$reservation->setCustomGeometry($road['coords']);$reservation->setCustomDistanceMeters((int) round($road['distance']));}} catch (\Throwable) {// Keep the request; the line simply stays unsnapped.}$reservations->save($reservation);$mailer->notifyAdmin($reservation);return $this->json(['reservation' => $this->serializeReservation($reservation)], Response::HTTP_CREATED);}/*** One reservation, so the map can paint a scheduled tour — in particular a* custom line, whose geometry lives nowhere else.*/#[Route('/reservations/{reference}', name: 'reservation_show', methods: ['GET'])]#[IsGranted('ROLE_USER')]public function showReservation(string $reference,TourReservationRepository $reservations,): JsonResponse {/** @var User $user */$user = $this->getUser();$reservation = $reservations->findOneBy(['reference' => $reference]);if ($reservation === null) {return $this->json(['error' => 'Reservation not found.'], Response::HTTP_NOT_FOUND);}if ($reservation->getUser()?->getId() !== $user->getId() && !$this->isGranted('ROLE_ADMIN')) {return $this->json(['error' => 'Not your reservation.'], Response::HTTP_FORBIDDEN);}return $this->json(['reservation' => $this->serializeReservation($reservation)]);}// ── Bookings ─────────────────────────────────────────────────────────/*** Place an order.** Open to signed-out visitors: a guest sends a `guest` block of name, email* and phone alongside the order and is registered and signed in as part of* placing it (see GuestAccountFactory for why an account is unavoidable).* Nothing is written until the tuk is locked, so a guest who loses the race* for a tuk leaves no half-made account behind.** Runs in a transaction with a pessimistic lock on the tuk: two passengers* tapping "Order" on the same tuk at the same instant must not both win.*/#[Route('/bookings', name: 'booking_create', methods: ['POST'])]public function createBooking(Request $request,BookingRepository $bookings,PaymentCardRepository $cards,GuestAccountFactory $guests,GuestAccountMailer $guestMailer,Security $security,CsrfTokenManagerInterface $csrfTokens,): JsonResponse {/** @var User|null $user */$user = $this->getUser();if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);}$body = $this->decodeBody($request);if ($body === null) {return $this->json(['error' => 'Malformed JSON body.'], Response::HTTP_BAD_REQUEST);}// Signed out? Build the passenger from what they typed at checkout. The// entity is only in memory at this point — it is persisted below, with// the booking, or not at all.$guest = null;$passwordToken = null;$guestPasswordRaw = null;if ($user === null) {try {$guest = $guests->create($body['guest'] ?? null);} catch (CheckoutException $e) {return $this->json(array_filter(['error' => $e->getMessage(),'accountExists' => $e->emailTaken ?: null,], static fn ($v) => $v !== null),$e->statusCode);}// Both are read now: signing the guest in below makes the firewall// erase the plain password, and the token is only ever handed out// once.$passwordToken = $guest->getPasswordSetToken();$guestPasswordRaw = $guest->getPlainPassword();$user = $guest;}// One live order per passenger, like a taxi app. A guest is new by// definition, so there is nothing to look up for them.if ($guest === null && $bookings->findActiveForUser($user) !== null) {return $this->json(['error' => 'You already have an active order. Cancel it before ordering another.'],Response::HTTP_CONFLICT);}$route = $this->em->getRepository(TourRoute::class)->find((int) ($body['routeId'] ?? 0));if ($route === null || !$route->isActive()) {return $this->json(['error' => 'Route not found.'], Response::HTTP_NOT_FOUND);}$paymentMethod = (string) ($body['paymentMethod'] ?? Booking::PAYMENT_CASH);if (!in_array($paymentMethod, Booking::PAYMENT_METHODS, true)) {return $this->json(['error' => 'Invalid payment method.'], Response::HTTP_BAD_REQUEST);}// The party size drives the fare, so it is validated before anything is// locked or written rather than being quietly clamped at save time.$passengers = $body['passengers'] ?? 1;if (!$this->pricing->isValidPartySize($passengers)) {return $this->json(['error' => sprintf('Passengers must be between 1 and %d.', TourPricing::MAX_PASSENGERS)],Response::HTTP_BAD_REQUEST);}$passengers = (int) $passengers;// A card order needs a card. Either one already saved on the account, or// — for a guest, who by definition has none — the details typed into the// payment step, which are turned into the same kind of record.$card = null;$newCard = null;if ($paymentMethod === Booking::PAYMENT_CARD) {$typed = $body['card'] ?? null;if (is_array($typed) && $typed !== []) {try {$newCard = $this->buildCard($user, $typed, $cards);} catch (CheckoutException $e) {return $this->json(['error' => $e->getMessage()], $e->statusCode);}$card = $newCard;} elseif ($guest !== null) {return $this->json(['error' => 'Please enter your card details.'], Response::HTTP_BAD_REQUEST);} else {$card = $this->em->getRepository(PaymentCard::class)->find((int) ($body['cardId'] ?? 0));if ($card === null || $card->getUser()->getId() !== $user->getId()) {return $this->json(['error' => 'Card not found.'], Response::HTTP_NOT_FOUND);}if ($card->isExpired()) {return $this->json(['error' => 'That card has expired.'], Response::HTTP_BAD_REQUEST);}}}$this->em->beginTransaction();try {$tuk = $this->em->getRepository(Tuk::class)->find((int) ($body['tukId'] ?? 0));if ($tuk === null) {$this->em->rollback();return $this->json(['error' => 'Tuk not found.'], Response::HTTP_NOT_FOUND);}// Lock this row until we commit, so a competing order blocks here.$this->em->lock($tuk, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE);if (!$tuk->isBookable() || $bookings->hasLiveBookingForTuk($tuk)) {$this->em->rollback();return $this->json(['error' => 'That tuk was just taken. Please choose another.'],Response::HTTP_CONFLICT);}$booking = new Booking();$booking->setUser($user);$booking->setTourRoute($route);$booking->setTuk($tuk);$booking->setPaymentMethod($paymentMethod);if ($card !== null) {$booking->setPaymentCard($card);}// Price is worked out from the route and the party size now, so// neither a later price change on the route nor a client that lies// about the total can rewrite what this passenger was quoted.$booking->setPriceAmd($this->pricing->priceFor($route, $passengers));$booking->setPassengers($passengers);$booking->setComment($this->cleanString($body['comment'] ?? null, 500));$pickupLat = $body['pickupLat'] ?? null;$pickupLng = $body['pickupLng'] ?? null;if (is_numeric($pickupLat) && is_numeric($pickupLng)) {$booking->setPickupLatitude((float) $pickupLat);$booking->setPickupLongitude((float) $pickupLng);} else {// No GPS: the tour starts at the route's first stop.$start = $route->getStartLocation();if ($start !== null) {$booking->setPickupLatitude($start->getLatitude());$booking->setPickupLongitude($start->getLongitude());$booking->setPickupAddress($start->getAddress() ?? $start->getName());}}if (isset($body['pickupAddress'])) {$booking->setPickupAddress($this->cleanString($body['pickupAddress'], 255));}// The tuk is now committed to this order.$tuk->setStatus(Tuk::STATUS_BUSY);// Only now, with the tuk safely ours, does a guest become a row in// the users table.if ($guest !== null) {$this->em->persist($guest);}if ($newCard !== null) {$this->persistCard($newCard, $cards);}$this->em->persist($booking);$this->em->flush();$this->em->commit();} catch (\Throwable $e) {if ($this->em->getConnection()->isTransactionActive()) {$this->em->rollback();}throw $e;}// Nudge the driver's app: a new job is waiting for them.$driver = $booking->getDriver();if ($driver !== null && $driver->getId() !== null) {$this->mercure->publish(MercurePublisher::driverTopic($driver->getId()), ['event' => 'job']);}$account = null;if ($guest !== null) {// Open the session before answering, so the very next call the sheet// makes — polling this order, opening the chat — is authenticated.// Never worth losing a paid-for booking over: if the firewall// refuses, the order still stands and a sign-in fixes the rest.$signedIn = true;try {$security->login($guest);} catch (\Throwable $e) {$signedIn = false;}// Carries the order recap, the password we generated for them and// the nudge to change it on their profile page.$guestMailer->sendWelcome($guest, $booking, (string) $passwordToken, (string) $guestPasswordRaw);$account = ['created' => true,'signedIn' => $signedIn,'email' => $guest->getEmail(),// Signing in migrates the session, and the CSRF token the page// was rendered with does not survive that — every later POST// from this still-open page (cancel, rate, tip) would be// rejected. Hand back a token minted against the new session so// the sheet can carry on.'csrf' => $csrfTokens->getToken('taxi_api')->getValue(),];}return $this->json(array_filter(['booking' => $this->serializeBooking($booking),'account' => $account,], static fn ($v) => $v !== null),Response::HTTP_CREATED);}/*** Poll a booking's status. The sheet calls this to follow the driver in.*/#[Route('/bookings/{reference}', name: 'booking_show', methods: ['GET'])]#[IsGranted('ROLE_USER')]public function showBooking(string $reference, BookingRepository $bookings): JsonResponse{$booking = $bookings->findOneByReference($reference);if ($booking === null) {return $this->json(['error' => 'Booking not found.'], Response::HTTP_NOT_FOUND);}// A passenger may only read their own order; the driver on the job may// read it too.if (!$this->canSee($booking)) {return $this->json(['error' => 'Not your booking.'], Response::HTTP_FORBIDDEN);}return $this->json(['booking' => $this->serializeBooking($booking)]);}/*** The passenger's current order, if they have one. Lets the sheet restore* itself after a page reload.*/#[Route('/bookings/active/current', name: 'booking_active', methods: ['GET'])]#[IsGranted('ROLE_USER')]public function activeBooking(BookingRepository $bookings): JsonResponse{/** @var User $user */$user = $this->getUser();$booking = $bookings->findActiveForUser($user);return $this->json(['booking' => $booking === null ? null : $this->serializeBooking($booking),]);}#[Route('/bookings/{reference}/cancel', name: 'booking_cancel', methods: ['POST'])]#[IsGranted('ROLE_USER')]public function cancelBooking(string $reference,Request $request,BookingRepository $bookings,): JsonResponse {if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);}$booking = $bookings->findOneByReference($reference);if ($booking === null) {return $this->json(['error' => 'Booking not found.'], Response::HTTP_NOT_FOUND);}if (!$this->canSee($booking)) {return $this->json(['error' => 'Not your booking.'], Response::HTTP_FORBIDDEN);}if (!$booking->isCancellable()) {return $this->json(['error' => 'This tour has already started and cannot be cancelled.'],Response::HTTP_CONFLICT);}$body = $this->decodeBody($request) ?? [];$booking->cancel($this->cleanString($body['reason'] ?? null, 255));// Hand the tuk back to the fleet.$tuk = $booking->getTuk();if ($tuk !== null && $tuk->getStatus() === Tuk::STATUS_BUSY) {$tuk->setStatus(Tuk::STATUS_AVAILABLE);}$this->em->flush();// Tell the driver their job was called off.$driver = $booking->getDriver();if ($driver !== null && $driver->getId() !== null) {$this->mercure->publish(MercurePublisher::driverTopic($driver->getId()), ['event' => 'status']);}return $this->json(['booking' => $this->serializeBooking($booking)]);}/*** Rate the driver after a completed tour.** Passenger-only and once-only: the driver cannot rate themselves, a tour* that has not finished cannot be rated, and a booking already carrying a* rating is not re-scored. The star folds into the driver's running average.*/#[Route('/bookings/{reference}/rate', name: 'booking_rate', methods: ['POST'])]#[IsGranted('ROLE_USER')]public function rateBooking(string $reference,Request $request,BookingRepository $bookings,): JsonResponse {/** @var User $user */$user = $this->getUser();if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);}$booking = $bookings->findOneByReference($reference);if ($booking === null) {return $this->json(['error' => 'Booking not found.'], Response::HTTP_NOT_FOUND);}// Only the passenger who took the tour may rate it.if ($booking->getUser()->getId() !== $user->getId()) {return $this->json(['error' => 'Not your booking.'], Response::HTTP_FORBIDDEN);}if ($booking->getStatus() !== Booking::STATUS_COMPLETED) {return $this->json(['error' => 'You can only rate a completed tour.'],Response::HTTP_CONFLICT);}if ($booking->getRating() !== null) {return $this->json(['error' => 'You have already rated this tour.'],Response::HTTP_CONFLICT);}$body = $this->decodeBody($request) ?? [];$rating = (int) ($body['rating'] ?? 0);if ($rating < 1 || $rating > 5) {return $this->json(['error' => 'Rating must be between 1 and 5.'], Response::HTTP_BAD_REQUEST);}$booking->setRating($rating);// Fold the star into the driver's running average.$driver = $booking->getDriver();if ($driver !== null) {$driver->addRating($rating);}$this->em->flush();return $this->json(['booking' => $this->serializeBooking($booking)]);}/*** Leave the driver a tip once the tour is over.** Like the fare, this is a record of intent rather than a card charge — no* gateway is wired up yet, so a "card" tip is settled the same way the fare* is. The guards mirror the rating endpoint: passenger only, completed* only, once only.*/#[Route('/bookings/{reference}/tip', name: 'booking_tip', methods: ['POST'])]#[IsGranted('ROLE_USER')]public function tipBooking(string $reference,Request $request,BookingRepository $bookings,): JsonResponse {/** @var User $user */$user = $this->getUser();if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);}$booking = $bookings->findOneByReference($reference);if ($booking === null) {return $this->json(['error' => 'Booking not found.'], Response::HTTP_NOT_FOUND);}// Only the passenger who took the tour may tip for it.if ($booking->getUser()->getId() !== $user->getId()) {return $this->json(['error' => 'Not your booking.'], Response::HTTP_FORBIDDEN);}if ($booking->getStatus() !== Booking::STATUS_COMPLETED) {return $this->json(['error' => 'You can only tip after the tour is finished.'],Response::HTTP_CONFLICT);}if ($booking->getTipAmd() !== null) {return $this->json(['error' => 'You have already tipped for this tour.'],Response::HTTP_CONFLICT);}$body = $this->decodeBody($request) ?? [];$amount = $body['amountAmd'] ?? 0;if (!is_numeric($amount)) {return $this->json(['error' => 'Please enter a tip amount.'], Response::HTTP_BAD_REQUEST);}$amount = (int) $amount;if ($amount < Booking::TIP_MIN_AMD || $amount > Booking::TIP_MAX_AMD) {return $this->json(['error' => sprintf('A tip must be between %d and %d AMD.',Booking::TIP_MIN_AMD,Booking::TIP_MAX_AMD),],Response::HTTP_BAD_REQUEST);}$method = (string) ($body['method'] ?? Booking::PAYMENT_CASH);if (!in_array($method, Booking::PAYMENT_METHODS, true)) {return $this->json(['error' => 'Invalid payment method.'], Response::HTTP_BAD_REQUEST);}// A card tip needs one of this passenger's own cards behind it.if ($method === Booking::PAYMENT_CARD) {$card = $this->em->getRepository(PaymentCard::class)->find((int) ($body['cardId'] ?? 0));if ($card === null || $card->getUser()->getId() !== $user->getId()) {return $this->json(['error' => 'Card not found.'], Response::HTTP_NOT_FOUND);}if ($card->isExpired()) {return $this->json(['error' => 'That card has expired.'], Response::HTTP_BAD_REQUEST);}}$booking->addTip($amount, $method);// Keep the driver's running total in step.$driver = $booking->getDriver();if ($driver !== null) {$driver->addTip($amount);}$this->em->flush();return $this->json(['booking' => $this->serializeBooking($booking)]);}// ── Cards ────────────────────────────────────────────────────────────#[Route('/cards', name: 'cards', methods: ['GET'])]#[IsGranted('ROLE_USER')]public function cards(PaymentCardRepository $cards): JsonResponse{/** @var User $user */$user = $this->getUser();$out = [];foreach ($cards->findForUser($user) as $card) {$out[] = $this->serializeCard($card);}return $this->json(['cards' => $out]);}/*** Save a card for later use.** The full number is used only to work out the brand and the last four* digits, and is then discarded — it is never written to the database. See* the note on the PaymentCard entity.*/#[Route('/cards', name: 'card_create', methods: ['POST'])]#[IsGranted('ROLE_USER')]public function createCard(Request $request, PaymentCardRepository $cards): JsonResponse{/** @var User $user */$user = $this->getUser();if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);}$body = $this->decodeBody($request);if ($body === null) {return $this->json(['error' => 'Malformed JSON body.'], Response::HTTP_BAD_REQUEST);}try {$card = $this->buildCard($user, $body, $cards);} catch (CheckoutException $e) {return $this->json(['error' => $e->getMessage()], $e->statusCode);}$this->persistCard($card, $cards);$this->em->flush();// The number went out of scope inside buildCard() and was never persisted.return $this->json(['card' => $this->serializeCard($card)], Response::HTTP_CREATED);}/*** Validate typed-in card details and build the record we keep of them.** Shared by "add a card" on the account page and by the payment step of an* order, where a guest types their card straight into checkout — both must* apply the same rules and both must forget the number the same way. The* card comes back unpersisted; the caller decides when it is written.** @param array<string, mixed> $data** @throws CheckoutException on a number, expiry or checksum we won't accept*/private function buildCard(User $user, array $data, PaymentCardRepository $cards): PaymentCard{$number = preg_replace('/\D/', '', (string) ($data['number'] ?? '')) ?? '';if (strlen($number) < 12 || strlen($number) > 19 || !$this->passesLuhn($number)) {throw CheckoutException::invalid('That card number is not valid.');}$expMonth = (int) ($data['expMonth'] ?? 0);$expYear = (int) ($data['expYear'] ?? 0);// Accept a two-digit year the way a card is printed.if ($expYear > 0 && $expYear < 100) {$expYear += 2000;}if ($expMonth < 1 || $expMonth > 12 || $expYear < 2000 || $expYear > 2100) {throw CheckoutException::invalid('That expiry date is not valid.');}$card = new PaymentCard();$card->setUser($user);$card->setBrand(PaymentCard::detectBrand($number));$card->setLast4(substr($number, -4));$card->setHolderName($this->cleanString($data['holderName'] ?? null, 100));$card->setExpMonth($expMonth);$card->setExpYear($expYear);if ($card->isExpired()) {throw CheckoutException::invalid('That card has already expired.');}// A guest's user row does not exist yet, so there is nothing of theirs to// query — their first card is, trivially, their first card.$existing = $user->getId() === null ? [] : $cards->findForUser($user);// First card added becomes the default; after that, only if asked. The// flag is only set here — clearing it on the others is left to whoever// saves this card, so an order that never completes cannot leave a// passenger with no default card at all.$card->setIsDefault((bool) ($data['isDefault'] ?? false) || $existing === []);return $card;// $number goes out of scope here and was never persisted.}/*** Save a card built by buildCard(), keeping the "only one default" rule.* Assumes the caller flushes.*/private function persistCard(PaymentCard $card, PaymentCardRepository $cards): void{if ($card->isDefault() && $card->getUser()->getId() !== null) {$cards->clearDefaultForUser($card->getUser());}$this->em->persist($card);}#[Route('/cards/{id}', name: 'card_delete', methods: ['DELETE'])]#[IsGranted('ROLE_USER')]public function deleteCard(int $id, Request $request): JsonResponse{/** @var User $user */$user = $this->getUser();if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);}$card = $this->em->getRepository(PaymentCard::class)->find($id);if ($card === null || $card->getUser()->getId() !== $user->getId()) {return $this->json(['error' => 'Card not found.'], Response::HTTP_NOT_FOUND);}$this->em->remove($card);$this->em->flush();return $this->json(['deleted' => true]);}/*** Make one of the passenger's cards the default. Clears the flag on the* others so only ever one holds it.*/#[Route('/cards/{id}/default', name: 'card_default', methods: ['POST'])]#[IsGranted('ROLE_USER')]public function setDefaultCard(int $id, Request $request, PaymentCardRepository $cards): JsonResponse{/** @var User $user */$user = $this->getUser();if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);}$card = $this->em->getRepository(PaymentCard::class)->find($id);if ($card === null || $card->getUser()->getId() !== $user->getId()) {return $this->json(['error' => 'Card not found.'], Response::HTTP_NOT_FOUND);}$cards->clearDefaultForUser($user, $card);$card->setIsDefault(true);$this->em->flush();return $this->json(['card' => $this->serializeCard($card)]);}// ── Serialization ────────────────────────────────────────────────────private function serializeRoute(TourRoute $route): array{$points = [];foreach ($route->getPoints() as $point) {$location = $point->getLocation();// A stop with no coordinates cannot be drawn or routed through.if ($location->getLatitude() === null || $location->getLongitude() === null) {continue;}$points[] = ['id' => $location->getId(),'name' => $location->getName(),'nameHy' => $location->getNameHy(),'nameRu' => $location->getNameRu(),'address' => $location->getAddress(),'category' => $location->getCategory(),'lat' => (float) $location->getLatitude(),'lng' => (float) $location->getLongitude(),'stopMinutes' => $point->getStopMinutes(),'sort' => $point->getSortOrder(),];}return ['id' => $route->getId(),'slug' => $route->getSlug(),'name' => $route->getName(),'nameHy' => $route->getNameHy(),'nameRu' => $route->getNameRu(),'description' => $route->getDescription(),'descriptionHy' => $route->getDescriptionHy(),'descriptionRu' => $route->getDescriptionRu(),'color' => $route->getColor(),'durationMinutes' => $route->getDurationMinutes(),// priceAmd is the one-passenger price, i.e. what the route card// shows "from". The full party-size table travels with it so the// sheet can re-price as the passenger changes the head count// without re-deriving the rules in JavaScript.'priceAmd' => $this->pricing->priceFor($route, 1),'basePriceAmd' => $this->pricing->basePrice($route),'priceTable' => $this->pricing->table($route),'maxPassengers' => TourPricing::MAX_PASSENGERS,'stopCount' => count($points),'points' => $points,// The pre-resolved road, so the client draws streets rather than// straight lines. Null until app:route:geometry has run.'geometry' => $route->getGeometry(),'distanceMeters' => $route->getDistanceMeters(),];}private function serializeTuk(Tuk $tuk, ?float $distance): array{$driver = $tuk->getDriver();return ['id' => $tuk->getId(),'code' => $tuk->getCode(),'plate' => $tuk->getPlateNumber(),'color' => $tuk->getColor(),'status' => $tuk->getStatus(), // 'available' | 'busy''seats' => $tuk->getSeats(),'photo' => $tuk->getPhoto(),'lat' => (float) $tuk->getLatitude(),'lng' => (float) $tuk->getLongitude(),'heading' => $tuk->getHeading(),'distance' => $distance === null ? null : round($distance),'eta' => $distance === null ? null : max(1, (int) ceil($distance / self::TUK_SPEED_M_PER_MIN)),'driver' => $driver === null ? null : $this->serializeDriver($driver),];}private function serializeDriver(Driver $driver): array{return ['id' => $driver->getId(),'name' => $driver->getName(),'photo' => $driver->getPhoto(),'rating' => $driver->getRating(),'ratingCount' => $driver->getRatingCount(),'tripCount' => $driver->getTripCount(),'languages' => $driver->getLanguages(),];}private function serializeBooking(Booking $booking): array{$tuk = $booking->getTuk();$driver = $booking->getDriver();$card = $booking->getPaymentCard();$route = $booking->getTourRoute();return ['reference' => $booking->getReference(),'status' => $booking->getStatus(),'statusLabel' => $booking->getStatusLabel(),'priceAmd' => $booking->getPriceAmd(),'passengers' => $booking->getPassengers(),'paymentMethod' => $booking->getPaymentMethod(),'paymentStatus' => $booking->getPaymentStatus(),'cancellable' => $booking->isCancellable(),'comment' => $booking->getComment(),'rating' => $booking->getRating(),'rateable' => $booking->getStatus() === Booking::STATUS_COMPLETED && $booking->getRating() === null,'tipAmd' => $booking->getTipAmd(),'tipMethod' => $booking->getTipMethod(),'tippable' => $booking->isTippable(),'tipPresets' => Booking::TIP_PRESETS_AMD,'tipMin' => Booking::TIP_MIN_AMD,'tipMax' => Booking::TIP_MAX_AMD,'createdAt' => $booking->getCreatedAt()->format(\DATE_ATOM),'pickup' => ['lat' => $booking->getPickupLatitude() === null ? null : (float) $booking->getPickupLatitude(),'lng' => $booking->getPickupLongitude() === null ? null : (float) $booking->getPickupLongitude(),'address' => $booking->getPickupAddress(),],'route' => ['id' => $route->getId(),'slug' => $route->getSlug(),'name' => $route->getName(),'nameHy' => $route->getNameHy(),'nameRu' => $route->getNameRu(),'color' => $route->getColor(),'durationMinutes' => $route->getDurationMinutes(),],'card' => $card === null ? null : $this->serializeCard($card),'tuk' => $tuk === null ? null : ['id' => $tuk->getId(),'code' => $tuk->getCode(),'plate' => $tuk->getPlateNumber(),'color' => $tuk->getColor(),'seats' => $tuk->getSeats(),// Live position, so the sheet can follow the tuk in.'lat' => $tuk->getLatitude() === null ? null : (float) $tuk->getLatitude(),'lng' => $tuk->getLongitude() === null ? null : (float) $tuk->getLongitude(),'heading' => $tuk->getHeading(),'online' => $tuk->isOnline(),],'driver' => $driver === null ? null : array_merge($this->serializeDriver($driver),// The driver's phone number is personal data, so it is released// only while they are actually on this job: not while the order// is still waiting to be accepted, and not once it is finished// or cancelled.['phone' => $this->driverPhoneFor($booking)]),];}private function serializeCard(PaymentCard $card): array{return ['id' => $card->getId(),'brand' => $card->getBrand(),'label' => $card->getBrandLabel(),'last4' => $card->getLast4(),'masked' => $card->getMaskedNumber(),'expMonth' => $card->getExpMonth(),'expYear' => $card->getExpYear(),'expired' => $card->isExpired(),'default' => $card->isDefault(),];}// ── Helpers ──────────────────────────────────────────────────────────/*** The driver's phone, but only while they are en route to or driving this* passenger. Null at every other point in the booking's life.*/private function driverPhoneFor(Booking $booking): ?string{$driver = $booking->getDriver();if ($driver === null) {return null;}$active = [Booking::STATUS_ACCEPTED,Booking::STATUS_ARRIVED,Booking::STATUS_IN_PROGRESS,];return in_array($booking->getStatus(), $active, true)? $driver->getUser()->getPhone(): null;}/*** The passenger who ordered, or the driver assigned to it.*/private function canSee(Booking $booking): bool{/** @var User|null $user */$user = $this->getUser();if ($user === null) {return false;}if ($booking->getUser()->getId() === $user->getId()) {return true;}$driver = $booking->getDriver();return $driver !== null && $driver->getUser()->getId() === $user->getId();}/*** @return array<string, mixed>|null null when the body is not a JSON object*/private function decodeBody(Request $request): ?array{$data = json_decode($request->getContent(), true);return is_array($data) ? $data : null;}private function cleanString(mixed $value, int $maxLength): ?string{if (!is_string($value)) {return null;}$value = trim($value);if ($value === '') {return null;}return mb_substr($value, 0, $maxLength);}/*** Turn the client's date/time string into an immutable date, or null if it* cannot be understood. Accepts the datetime-local shape the form sends* ("2026-07-18T10:00") and anything else strtotime handles.*/private function parseScheduledAt(mixed $value): ?\DateTimeImmutable{if (!is_string($value) || trim($value) === '') {return null;}try {return new \DateTimeImmutable($value);} catch (\Exception) {return null;}}/*** Turn the ordered list of location ids the passenger picked into the stops* stored on the reservation.** Everything about a stop — its name and its coordinates — is read from the* Location row here. The request body contributes nothing but the id and* the order, so a client cannot invent a place, move an existing one, or* route the tour somewhere we do not operate.** @return array<int, array{locationId: int, lat: float, lng: float, label: string}>|null* null when the selection is unusable*/private function resolveCustomStops(mixed $raw, LocationRepository $locations): ?array{if (!is_array($raw)) {return null;}$out = [];$previous = null;foreach ($raw as $id) {if (!is_numeric($id)) {continue;}$id = (int) $id;// The same place twice in a row is not a second stop.if ($id === $previous) {continue;}$location = $locations->find($id);if ($location === null|| !$location->isActive()|| $location->getLatitude() === null|| $location->getLongitude() === null) {// An unknown or unmappable stop is a broken request, not one to// silently shorten — the passenger would get a different tour// from the one they asked for.return null;}$out[] = ['locationId' => $id,'lat' => round((float) $location->getLatitude(), 6),'lng' => round((float) $location->getLongitude(), 6),'label' => $location->getName(),];$previous = $id;if (count($out) >= TourReservation::CUSTOM_MAX_POINTS) {break;}}return count($out) >= TourReservation::CUSTOM_MIN_POINTS ? $out : null;}private function serializeReservation(TourReservation $reservation): array{$route = $reservation->getTourRoute();return ['reference' => $reservation->getReference(),'kind' => $reservation->getKind(),'status' => $reservation->getStatus(),'statusLabel' => $reservation->getStatusLabel(),'scheduledAt' => $reservation->getScheduledAt()->format(\DATE_ATOM),'passengers' => $reservation->getPassengers(),'priceAmd' => $reservation->getPriceAmd(),'pickupAddress' => $reservation->getPickupAddress(),'note' => $reservation->getNote(),'tourName' => $reservation->getTourName(),'color' => $reservation->getTourColor(),'route' => $route === null ? null : ['id' => $route->getId(),'name' => $route->getName(),'nameHy' => $route->getNameHy(),'nameRu' => $route->getNameRu(),'color' => $route->getColor(),],// Only a drawn tour carries these; for one of our lines the client// already has the geometry from /api/routes.'custom' => !$reservation->isCustom() ? null : ['title' => $reservation->getCustomTitle(),'points' => $reservation->getCustomPoints() ?? [],'geometry' => $reservation->getCustomGeometry(),'distanceMeters' => $reservation->getCustomDistanceMeters(),],];}/*** Luhn check digit — catches typos before we bother the passenger.*/private function passesLuhn(string $number): bool{$sum = 0;$double = false;for ($i = strlen($number) - 1; $i >= 0; $i--) {$digit = (int) $number[$i];if ($double) {$digit *= 2;if ($digit > 9) {$digit -= 9;}}$sum += $digit;$double = !$double;}return $sum % 10 === 0;}}