src/Controller/TaxiController.php line 140

  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\Booking;
  4. use App\Entity\Driver;
  5. use App\Entity\Location;
  6. use App\Entity\PaymentCard;
  7. use App\Entity\TourReservation;
  8. use App\Entity\TourRoute;
  9. use App\Entity\Tuk;
  10. use App\Entity\User;
  11. use App\Repository\BookingRepository;
  12. use App\Repository\LocationRepository;
  13. use App\Repository\PaymentCardRepository;
  14. use App\Repository\TourReservationRepository;
  15. use App\Repository\TourRouteRepository;
  16. use App\Repository\TukRepository;
  17. use App\Service\Checkout\CheckoutException;
  18. use App\Service\Checkout\GuestAccountFactory;
  19. use App\Service\Checkout\GuestAccountMailer;
  20. use App\Service\Mercure\MercurePublisher;
  21. use App\Service\Pricing\TourPricing;
  22. use App\Service\Reservation\ReservationMailer;
  23. use App\Services\Routing\RoadGeometryProvider;
  24. use Doctrine\ORM\EntityManagerInterface;
  25. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  26. use Symfony\Bundle\SecurityBundle\Security;
  27. use Symfony\Component\HttpFoundation\JsonResponse;
  28. use Symfony\Component\HttpFoundation\Request;
  29. use Symfony\Component\HttpFoundation\Response;
  30. use Symfony\Component\Routing\Annotation\Route;
  31. use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
  32. use Symfony\Component\Security\Http\Attribute\IsGranted;
  33. /**
  34.  * The booking API behind the Yandex-Taxi-style bottom sheet.
  35.  *
  36.  * Flow: pick a route → the map paints its road → pick a tuk from the ones shown
  37.  * → choose cash or card → order. Every response is JSON; the sheet is rendered
  38.  * client-side.
  39.  */
  40. #[Route('/api'name'api_')]
  41. class TaxiController extends AbstractController
  42. {
  43.     /**
  44.      * Assumed average tuk-tuk speed through Yerevan traffic, in metres per
  45.      * minute (~18 km/h). Used to turn a distance into a rough ETA.
  46.      */
  47.     private const TUK_SPEED_M_PER_MIN 300.0;
  48.     public function __construct(
  49.         private readonly EntityManagerInterface $em,
  50.         private readonly MercurePublisher $mercure,
  51.         private readonly TourPricing $pricing,
  52.     ) {
  53.     }
  54.     // ── Routes ───────────────────────────────────────────────────────────
  55.     /**
  56.      * Every active tour route with its ordered stops. The client feeds the
  57.      * stop coordinates to Yandex's multi-router to paint the road.
  58.      */
  59.     #[Route('/routes'name'routes'methods: ['GET'])]
  60.     public function routes(TourRouteRepository $routes): JsonResponse
  61.     {
  62.         $out = [];
  63.         foreach ($routes->findAllActiveWithPoints() as $route) {
  64.             $out[] = $this->serializeRoute($route);
  65.         }
  66.         return $this->json([
  67.             'routes' => $out,
  68.             // A passenger-drawn tour has no route row to carry a table, so the
  69.             // client gets the custom one here rather than re-deriving the
  70.             // surcharges itself.
  71.             'customPriceTable' => $this->pricing->customTable(),
  72.             'maxPassengers'    => TourPricing::MAX_PASSENGERS,
  73.         ]);
  74.     }
  75.     #[Route('/routes/{slug}'name'route_show'methods: ['GET'])]
  76.     public function route(string $slugTourRouteRepository $routes): JsonResponse
  77.     {
  78.         $route $routes->findBySlugWithPoints($slug);
  79.         if ($route === null) {
  80.             return $this->json(['error' => 'Route not found.'], Response::HTTP_NOT_FOUND);
  81.         }
  82.         return $this->json(['route' => $this->serializeRoute($route)]);
  83.     }
  84.     // ── Tuks ─────────────────────────────────────────────────────────────
  85.     /**
  86.      * The tuks to draw on the map once a route is chosen.
  87.      *
  88.      * With lat/lng we return the bookable tuks nearest that point, each with a
  89.      * distance and ETA. Without them (the visitor declined GPS) we fall back to
  90.      * every bookable tuk, unsorted — there is no "nearest" without a position.
  91.      */
  92.     #[Route('/tuks'name'tuks'methods: ['GET'])]
  93.     public function tuks(Request $requestTukRepository $tuks): JsonResponse
  94.     {
  95.         $lat $request->query->get('lat');
  96.         $lng $request->query->get('lng');
  97.         // Clamp so a caller cannot ask for the whole country.
  98.         $radius = (float) $request->query->get('radius''3000');
  99.         $radius max(100.0min($radius20000.0));
  100.         $out = [];
  101.         if (is_numeric($lat) && is_numeric($lng)) {
  102.             foreach ($tuks->findBookableNear((float) $lat, (float) $lng$radius) as $row) {
  103.                 $out[] = $this->serializeTuk($row['tuk'], $row['distance']);
  104.             }
  105.         } else {
  106.             foreach ($tuks->findAllBookable() as $tuk) {
  107.                 $out[] = $this->serializeTuk($tuknull);
  108.             }
  109.         }
  110.         return $this->json([
  111.             'tuks'  => $out,
  112.             'count' => count($out),
  113.         ]);
  114.     }
  115.     /**
  116.      * Every live tuk to paint on the browsing map — free ones and ones already
  117.      * on a tour — each with its status so the client can colour them (free vs
  118.      * busy). This is the ambient "see the fleet moving" view, distinct from
  119.      * /tuks which returns only the bookable tuks for the selection step.
  120.      */
  121.     #[Route('/tuks/map'name'tuks_map'methods: ['GET'])]
  122.     public function tuksMap(TukRepository $tuks): JsonResponse
  123.     {
  124.         $out = [];
  125.         foreach ($tuks->findAllLive() as $tuk) {
  126.             $out[] = $this->serializeTuk($tuknull);
  127.         }
  128.         return $this->json([
  129.             'tuks'  => $out,
  130.             'count' => count($out),
  131.         ]);
  132.     }
  133.     // ── Reservations (advance bookings) ──────────────────────────────────
  134.     /**
  135.      * Request a tour for a future date and time.
  136.      *
  137.      * Unlike a live booking this assigns no tuk and starts no state machine: it
  138.      * records the passenger's wish, emails the back office, and waits for a human
  139.      * to approve or decline it. The passenger is emailed the outcome.
  140.      */
  141.     #[Route('/reservations'name'reservation_create'methods: ['POST'])]
  142.     #[IsGranted('ROLE_USER')]
  143.     public function createReservation(
  144.         Request $request,
  145.         TourReservationRepository $reservations,
  146.         ReservationMailer $mailer,
  147.     ): JsonResponse {
  148.         /** @var User $user */
  149.         $user $this->getUser();
  150.         if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {
  151.             return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
  152.         }
  153.         $body $this->decodeBody($request);
  154.         if ($body === null) {
  155.             return $this->json(['error' => 'Malformed JSON body.'], Response::HTTP_BAD_REQUEST);
  156.         }
  157.         $route $this->em->getRepository(TourRoute::class)->find((int) ($body['routeId'] ?? 0));
  158.         if ($route === null || !$route->isActive()) {
  159.             return $this->json(['error' => 'Route not found.'], Response::HTTP_NOT_FOUND);
  160.         }
  161.         // Parse the requested date+time. The client sends an ISO string
  162.         // ("2026-07-18T10:00"); reject anything unparseable or in the past.
  163.         $when $this->parseScheduledAt($body['scheduledAt'] ?? null);
  164.         if ($when === null) {
  165.             return $this->json(['error' => 'Please choose a valid date and time.'], Response::HTTP_BAD_REQUEST);
  166.         }
  167.         if ($when < new \DateTimeImmutable('+1 hour')) {
  168.             return $this->json(['error' => 'Please pick a time at least an hour from now.'], Response::HTTP_BAD_REQUEST);
  169.         }
  170.         $passengers $body['passengers'] ?? 1;
  171.         // A tuk seats three, so a tour party is capped at three.
  172.         if (!$this->pricing->isValidPartySize($passengers)) {
  173.             return $this->json(
  174.                 ['error' => sprintf('Passengers must be between 1 and %d.'TourPricing::MAX_PASSENGERS)],
  175.                 Response::HTTP_BAD_REQUEST
  176.             );
  177.         }
  178.         $passengers = (int) $passengers;
  179.         $reservation = new TourReservation();
  180.         $reservation->setUser($user);
  181.         $reservation->setTourRoute($route);
  182.         $reservation->setScheduledAt($when);
  183.         $reservation->setPassengers($passengers);
  184.         $reservation->setPickupAddress($this->cleanString($body['pickupAddress'] ?? null255));
  185.         $reservation->setNote($this->cleanString($body['note'] ?? null500));
  186.         // Snapshot the price — party size included — so neither a later route
  187.         // price change nor a different head count rewrites this quote.
  188.         $reservation->setPriceAmd($this->pricing->priceFor($route$passengers));
  189.         $reservations->save($reservation);
  190.         // Let the back office know there is something to review (best-effort).
  191.         $mailer->notifyAdmin($reservation);
  192.         return $this->json(['reservation' => $this->serializeReservation($reservation)], Response::HTTP_CREATED);
  193.     }
  194.     /**
  195.      * Request a tour the passenger put together themselves.
  196.      *
  197.      * Same review-then-approve lifecycle as a reservation against one of our
  198.      * published lines — the difference is that there is no TourRoute row behind
  199.      * it, just an ordered pick of stops from our own catalogue. The stops are
  200.      * chosen by id, never by coordinate: the client cannot name a place we do
  201.      * not cover, and the latitude/longitude written to the reservation always
  202.      * come from the Location row, not from the request body.
  203.      */
  204.     #[Route('/reservations/custom'name'reservation_custom'methods: ['POST'])]
  205.     #[IsGranted('ROLE_USER')]
  206.     public function createCustomReservation(
  207.         Request $request,
  208.         TourReservationRepository $reservations,
  209.         ReservationMailer $mailer,
  210.         RoadGeometryProvider $roads,
  211.         LocationRepository $locations,
  212.     ): JsonResponse {
  213.         /** @var User $user */
  214.         $user $this->getUser();
  215.         if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {
  216.             return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
  217.         }
  218.         $body $this->decodeBody($request);
  219.         if ($body === null) {
  220.             return $this->json(['error' => 'Malformed JSON body.'], Response::HTTP_BAD_REQUEST);
  221.         }
  222.         $points $this->resolveCustomStops($body['locationIds'] ?? null$locations);
  223.         if ($points === null) {
  224.             return $this->json(
  225.                 [
  226.                     'error' => sprintf(
  227.                         'Choose between %d and %d stops from the list.',
  228.                         TourReservation::CUSTOM_MIN_POINTS,
  229.                         TourReservation::CUSTOM_MAX_POINTS
  230.                     ),
  231.                 ],
  232.                 Response::HTTP_BAD_REQUEST
  233.             );
  234.         }
  235.         $when $this->parseScheduledAt($body['scheduledAt'] ?? null);
  236.         if ($when === null) {
  237.             return $this->json(['error' => 'Please choose a valid date and time.'], Response::HTTP_BAD_REQUEST);
  238.         }
  239.         if ($when < new \DateTimeImmutable('+1 hour')) {
  240.             return $this->json(['error' => 'Please pick a time at least an hour from now.'], Response::HTTP_BAD_REQUEST);
  241.         }
  242.         $passengers $body['passengers'] ?? 1;
  243.         if (!$this->pricing->isValidPartySize($passengers)) {
  244.             return $this->json(
  245.                 ['error' => sprintf('Passengers must be between 1 and %d.'TourPricing::MAX_PASSENGERS)],
  246.                 Response::HTTP_BAD_REQUEST
  247.             );
  248.         }
  249.         $passengers = (int) $passengers;
  250.         $reservation = new TourReservation();
  251.         $reservation->setKind(TourReservation::KIND_CUSTOM);
  252.         $reservation->setUser($user);
  253.         $reservation->setScheduledAt($when);
  254.         $reservation->setPassengers($passengers);
  255.         $reservation->setCustomTitle($this->cleanString($body['title'] ?? null120));
  256.         $reservation->setCustomPoints($points);
  257.         $reservation->setPickupAddress($this->cleanString($body['pickupAddress'] ?? null255));
  258.         $reservation->setNote($this->cleanString($body['note'] ?? null500));
  259.         $reservation->setPriceAmd($this->pricing->priceForCustom($passengers));
  260.         // Snap the drawn line to real streets. Best-effort on purpose: OSRM
  261.         // being unreachable must not lose the passenger's request — the map
  262.         // then just joins their stops with straight lines.
  263.         try {
  264.             $road $roads->fetch(array_map(
  265.                 static fn (array $p) => [$p['lat'], $p['lng']],
  266.                 $points
  267.             ));
  268.             if ($road !== null) {
  269.                 $reservation->setCustomGeometry($road['coords']);
  270.                 $reservation->setCustomDistanceMeters((int) round($road['distance']));
  271.             }
  272.         } catch (\Throwable) {
  273.             // Keep the request; the line simply stays unsnapped.
  274.         }
  275.         $reservations->save($reservation);
  276.         $mailer->notifyAdmin($reservation);
  277.         return $this->json(['reservation' => $this->serializeReservation($reservation)], Response::HTTP_CREATED);
  278.     }
  279.     /**
  280.      * One reservation, so the map can paint a scheduled tour — in particular a
  281.      * custom line, whose geometry lives nowhere else.
  282.      */
  283.     #[Route('/reservations/{reference}'name'reservation_show'methods: ['GET'])]
  284.     #[IsGranted('ROLE_USER')]
  285.     public function showReservation(
  286.         string $reference,
  287.         TourReservationRepository $reservations,
  288.     ): JsonResponse {
  289.         /** @var User $user */
  290.         $user $this->getUser();
  291.         $reservation $reservations->findOneBy(['reference' => $reference]);
  292.         if ($reservation === null) {
  293.             return $this->json(['error' => 'Reservation not found.'], Response::HTTP_NOT_FOUND);
  294.         }
  295.         if ($reservation->getUser()?->getId() !== $user->getId() && !$this->isGranted('ROLE_ADMIN')) {
  296.             return $this->json(['error' => 'Not your reservation.'], Response::HTTP_FORBIDDEN);
  297.         }
  298.         return $this->json(['reservation' => $this->serializeReservation($reservation)]);
  299.     }
  300.     // ── Bookings ─────────────────────────────────────────────────────────
  301.     /**
  302.      * Place an order.
  303.      *
  304.      * Open to signed-out visitors: a guest sends a `guest` block of name, email
  305.      * and phone alongside the order and is registered and signed in as part of
  306.      * placing it (see GuestAccountFactory for why an account is unavoidable).
  307.      * Nothing is written until the tuk is locked, so a guest who loses the race
  308.      * for a tuk leaves no half-made account behind.
  309.      *
  310.      * Runs in a transaction with a pessimistic lock on the tuk: two passengers
  311.      * tapping "Order" on the same tuk at the same instant must not both win.
  312.      */
  313.     #[Route('/bookings'name'booking_create'methods: ['POST'])]
  314.     public function createBooking(
  315.         Request $request,
  316.         BookingRepository $bookings,
  317.         PaymentCardRepository $cards,
  318.         GuestAccountFactory $guests,
  319.         GuestAccountMailer $guestMailer,
  320.         Security $security,
  321.         CsrfTokenManagerInterface $csrfTokens,
  322.     ): JsonResponse {
  323.         /** @var User|null $user */
  324.         $user $this->getUser();
  325.         if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {
  326.             return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
  327.         }
  328.         $body $this->decodeBody($request);
  329.         if ($body === null) {
  330.             return $this->json(['error' => 'Malformed JSON body.'], Response::HTTP_BAD_REQUEST);
  331.         }
  332.         // Signed out? Build the passenger from what they typed at checkout. The
  333.         // entity is only in memory at this point — it is persisted below, with
  334.         // the booking, or not at all.
  335.         $guest            null;
  336.         $passwordToken    null;
  337.         $guestPasswordRaw null;
  338.         if ($user === null) {
  339.             try {
  340.                 $guest $guests->create($body['guest'] ?? null);
  341.             } catch (CheckoutException $e) {
  342.                 return $this->json(
  343.                     array_filter([
  344.                         'error'         => $e->getMessage(),
  345.                         'accountExists' => $e->emailTaken ?: null,
  346.                     ], static fn ($v) => $v !== null),
  347.                     $e->statusCode
  348.                 );
  349.             }
  350.             // Both are read now: signing the guest in below makes the firewall
  351.             // erase the plain password, and the token is only ever handed out
  352.             // once.
  353.             $passwordToken    $guest->getPasswordSetToken();
  354.             $guestPasswordRaw $guest->getPlainPassword();
  355.             $user             $guest;
  356.         }
  357.         // One live order per passenger, like a taxi app. A guest is new by
  358.         // definition, so there is nothing to look up for them.
  359.         if ($guest === null && $bookings->findActiveForUser($user) !== null) {
  360.             return $this->json(
  361.                 ['error' => 'You already have an active order. Cancel it before ordering another.'],
  362.                 Response::HTTP_CONFLICT
  363.             );
  364.         }
  365.         $route $this->em->getRepository(TourRoute::class)->find((int) ($body['routeId'] ?? 0));
  366.         if ($route === null || !$route->isActive()) {
  367.             return $this->json(['error' => 'Route not found.'], Response::HTTP_NOT_FOUND);
  368.         }
  369.         $paymentMethod = (string) ($body['paymentMethod'] ?? Booking::PAYMENT_CASH);
  370.         if (!in_array($paymentMethodBooking::PAYMENT_METHODStrue)) {
  371.             return $this->json(['error' => 'Invalid payment method.'], Response::HTTP_BAD_REQUEST);
  372.         }
  373.         // The party size drives the fare, so it is validated before anything is
  374.         // locked or written rather than being quietly clamped at save time.
  375.         $passengers $body['passengers'] ?? 1;
  376.         if (!$this->pricing->isValidPartySize($passengers)) {
  377.             return $this->json(
  378.                 ['error' => sprintf('Passengers must be between 1 and %d.'TourPricing::MAX_PASSENGERS)],
  379.                 Response::HTTP_BAD_REQUEST
  380.             );
  381.         }
  382.         $passengers = (int) $passengers;
  383.         // A card order needs a card. Either one already saved on the account, or
  384.         // — for a guest, who by definition has none — the details typed into the
  385.         // payment step, which are turned into the same kind of record.
  386.         $card    null;
  387.         $newCard null;
  388.         if ($paymentMethod === Booking::PAYMENT_CARD) {
  389.             $typed $body['card'] ?? null;
  390.             if (is_array($typed) && $typed !== []) {
  391.                 try {
  392.                     $newCard $this->buildCard($user$typed$cards);
  393.                 } catch (CheckoutException $e) {
  394.                     return $this->json(['error' => $e->getMessage()], $e->statusCode);
  395.                 }
  396.                 $card $newCard;
  397.             } elseif ($guest !== null) {
  398.                 return $this->json(['error' => 'Please enter your card details.'], Response::HTTP_BAD_REQUEST);
  399.             } else {
  400.                 $card $this->em->getRepository(PaymentCard::class)->find((int) ($body['cardId'] ?? 0));
  401.                 if ($card === null || $card->getUser()->getId() !== $user->getId()) {
  402.                     return $this->json(['error' => 'Card not found.'], Response::HTTP_NOT_FOUND);
  403.                 }
  404.                 if ($card->isExpired()) {
  405.                     return $this->json(['error' => 'That card has expired.'], Response::HTTP_BAD_REQUEST);
  406.                 }
  407.             }
  408.         }
  409.         $this->em->beginTransaction();
  410.         try {
  411.             $tuk $this->em->getRepository(Tuk::class)->find((int) ($body['tukId'] ?? 0));
  412.             if ($tuk === null) {
  413.                 $this->em->rollback();
  414.                 return $this->json(['error' => 'Tuk not found.'], Response::HTTP_NOT_FOUND);
  415.             }
  416.             // Lock this row until we commit, so a competing order blocks here.
  417.             $this->em->lock($tuk\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE);
  418.             if (!$tuk->isBookable() || $bookings->hasLiveBookingForTuk($tuk)) {
  419.                 $this->em->rollback();
  420.                 return $this->json(
  421.                     ['error' => 'That tuk was just taken. Please choose another.'],
  422.                     Response::HTTP_CONFLICT
  423.                 );
  424.             }
  425.             $booking = new Booking();
  426.             $booking->setUser($user);
  427.             $booking->setTourRoute($route);
  428.             $booking->setTuk($tuk);
  429.             $booking->setPaymentMethod($paymentMethod);
  430.             if ($card !== null) {
  431.                 $booking->setPaymentCard($card);
  432.             }
  433.             // Price is worked out from the route and the party size now, so
  434.             // neither a later price change on the route nor a client that lies
  435.             // about the total can rewrite what this passenger was quoted.
  436.             $booking->setPriceAmd($this->pricing->priceFor($route$passengers));
  437.             $booking->setPassengers($passengers);
  438.             $booking->setComment($this->cleanString($body['comment'] ?? null500));
  439.             $pickupLat $body['pickupLat'] ?? null;
  440.             $pickupLng $body['pickupLng'] ?? null;
  441.             if (is_numeric($pickupLat) && is_numeric($pickupLng)) {
  442.                 $booking->setPickupLatitude((float) $pickupLat);
  443.                 $booking->setPickupLongitude((float) $pickupLng);
  444.             } else {
  445.                 // No GPS: the tour starts at the route's first stop.
  446.                 $start $route->getStartLocation();
  447.                 if ($start !== null) {
  448.                     $booking->setPickupLatitude($start->getLatitude());
  449.                     $booking->setPickupLongitude($start->getLongitude());
  450.                     $booking->setPickupAddress($start->getAddress() ?? $start->getName());
  451.                 }
  452.             }
  453.             if (isset($body['pickupAddress'])) {
  454.                 $booking->setPickupAddress($this->cleanString($body['pickupAddress'], 255));
  455.             }
  456.             // The tuk is now committed to this order.
  457.             $tuk->setStatus(Tuk::STATUS_BUSY);
  458.             // Only now, with the tuk safely ours, does a guest become a row in
  459.             // the users table.
  460.             if ($guest !== null) {
  461.                 $this->em->persist($guest);
  462.             }
  463.             if ($newCard !== null) {
  464.                 $this->persistCard($newCard$cards);
  465.             }
  466.             $this->em->persist($booking);
  467.             $this->em->flush();
  468.             $this->em->commit();
  469.         } catch (\Throwable $e) {
  470.             if ($this->em->getConnection()->isTransactionActive()) {
  471.                 $this->em->rollback();
  472.             }
  473.             throw $e;
  474.         }
  475.         // Nudge the driver's app: a new job is waiting for them.
  476.         $driver $booking->getDriver();
  477.         if ($driver !== null && $driver->getId() !== null) {
  478.             $this->mercure->publish(MercurePublisher::driverTopic($driver->getId()), ['event' => 'job']);
  479.         }
  480.         $account null;
  481.         if ($guest !== null) {
  482.             // Open the session before answering, so the very next call the sheet
  483.             // makes — polling this order, opening the chat — is authenticated.
  484.             // Never worth losing a paid-for booking over: if the firewall
  485.             // refuses, the order still stands and a sign-in fixes the rest.
  486.             $signedIn true;
  487.             try {
  488.                 $security->login($guest);
  489.             } catch (\Throwable $e) {
  490.                 $signedIn false;
  491.             }
  492.             // Carries the order recap, the password we generated for them and
  493.             // the nudge to change it on their profile page.
  494.             $guestMailer->sendWelcome($guest$booking, (string) $passwordToken, (string) $guestPasswordRaw);
  495.             $account = [
  496.                 'created'  => true,
  497.                 'signedIn' => $signedIn,
  498.                 'email'    => $guest->getEmail(),
  499.                 // Signing in migrates the session, and the CSRF token the page
  500.                 // was rendered with does not survive that — every later POST
  501.                 // from this still-open page (cancel, rate, tip) would be
  502.                 // rejected. Hand back a token minted against the new session so
  503.                 // the sheet can carry on.
  504.                 'csrf'     => $csrfTokens->getToken('taxi_api')->getValue(),
  505.             ];
  506.         }
  507.         return $this->json(
  508.             array_filter([
  509.                 'booking' => $this->serializeBooking($booking),
  510.                 'account' => $account,
  511.             ], static fn ($v) => $v !== null),
  512.             Response::HTTP_CREATED
  513.         );
  514.     }
  515.     /**
  516.      * Poll a booking's status. The sheet calls this to follow the driver in.
  517.      */
  518.     #[Route('/bookings/{reference}'name'booking_show'methods: ['GET'])]
  519.     #[IsGranted('ROLE_USER')]
  520.     public function showBooking(string $referenceBookingRepository $bookings): JsonResponse
  521.     {
  522.         $booking $bookings->findOneByReference($reference);
  523.         if ($booking === null) {
  524.             return $this->json(['error' => 'Booking not found.'], Response::HTTP_NOT_FOUND);
  525.         }
  526.         // A passenger may only read their own order; the driver on the job may
  527.         // read it too.
  528.         if (!$this->canSee($booking)) {
  529.             return $this->json(['error' => 'Not your booking.'], Response::HTTP_FORBIDDEN);
  530.         }
  531.         return $this->json(['booking' => $this->serializeBooking($booking)]);
  532.     }
  533.     /**
  534.      * The passenger's current order, if they have one. Lets the sheet restore
  535.      * itself after a page reload.
  536.      */
  537.     #[Route('/bookings/active/current'name'booking_active'methods: ['GET'])]
  538.     #[IsGranted('ROLE_USER')]
  539.     public function activeBooking(BookingRepository $bookings): JsonResponse
  540.     {
  541.         /** @var User $user */
  542.         $user    $this->getUser();
  543.         $booking $bookings->findActiveForUser($user);
  544.         return $this->json([
  545.             'booking' => $booking === null null $this->serializeBooking($booking),
  546.         ]);
  547.     }
  548.     #[Route('/bookings/{reference}/cancel'name'booking_cancel'methods: ['POST'])]
  549.     #[IsGranted('ROLE_USER')]
  550.     public function cancelBooking(
  551.         string $reference,
  552.         Request $request,
  553.         BookingRepository $bookings,
  554.     ): JsonResponse {
  555.         if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {
  556.             return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
  557.         }
  558.         $booking $bookings->findOneByReference($reference);
  559.         if ($booking === null) {
  560.             return $this->json(['error' => 'Booking not found.'], Response::HTTP_NOT_FOUND);
  561.         }
  562.         if (!$this->canSee($booking)) {
  563.             return $this->json(['error' => 'Not your booking.'], Response::HTTP_FORBIDDEN);
  564.         }
  565.         if (!$booking->isCancellable()) {
  566.             return $this->json(
  567.                 ['error' => 'This tour has already started and cannot be cancelled.'],
  568.                 Response::HTTP_CONFLICT
  569.             );
  570.         }
  571.         $body $this->decodeBody($request) ?? [];
  572.         $booking->cancel($this->cleanString($body['reason'] ?? null255));
  573.         // Hand the tuk back to the fleet.
  574.         $tuk $booking->getTuk();
  575.         if ($tuk !== null && $tuk->getStatus() === Tuk::STATUS_BUSY) {
  576.             $tuk->setStatus(Tuk::STATUS_AVAILABLE);
  577.         }
  578.         $this->em->flush();
  579.         // Tell the driver their job was called off.
  580.         $driver $booking->getDriver();
  581.         if ($driver !== null && $driver->getId() !== null) {
  582.             $this->mercure->publish(MercurePublisher::driverTopic($driver->getId()), ['event' => 'status']);
  583.         }
  584.         return $this->json(['booking' => $this->serializeBooking($booking)]);
  585.     }
  586.     /**
  587.      * Rate the driver after a completed tour.
  588.      *
  589.      * Passenger-only and once-only: the driver cannot rate themselves, a tour
  590.      * that has not finished cannot be rated, and a booking already carrying a
  591.      * rating is not re-scored. The star folds into the driver's running average.
  592.      */
  593.     #[Route('/bookings/{reference}/rate'name'booking_rate'methods: ['POST'])]
  594.     #[IsGranted('ROLE_USER')]
  595.     public function rateBooking(
  596.         string $reference,
  597.         Request $request,
  598.         BookingRepository $bookings,
  599.     ): JsonResponse {
  600.         /** @var User $user */
  601.         $user $this->getUser();
  602.         if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {
  603.             return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
  604.         }
  605.         $booking $bookings->findOneByReference($reference);
  606.         if ($booking === null) {
  607.             return $this->json(['error' => 'Booking not found.'], Response::HTTP_NOT_FOUND);
  608.         }
  609.         // Only the passenger who took the tour may rate it.
  610.         if ($booking->getUser()->getId() !== $user->getId()) {
  611.             return $this->json(['error' => 'Not your booking.'], Response::HTTP_FORBIDDEN);
  612.         }
  613.         if ($booking->getStatus() !== Booking::STATUS_COMPLETED) {
  614.             return $this->json(
  615.                 ['error' => 'You can only rate a completed tour.'],
  616.                 Response::HTTP_CONFLICT
  617.             );
  618.         }
  619.         if ($booking->getRating() !== null) {
  620.             return $this->json(
  621.                 ['error' => 'You have already rated this tour.'],
  622.                 Response::HTTP_CONFLICT
  623.             );
  624.         }
  625.         $body   $this->decodeBody($request) ?? [];
  626.         $rating = (int) ($body['rating'] ?? 0);
  627.         if ($rating || $rating 5) {
  628.             return $this->json(['error' => 'Rating must be between 1 and 5.'], Response::HTTP_BAD_REQUEST);
  629.         }
  630.         $booking->setRating($rating);
  631.         // Fold the star into the driver's running average.
  632.         $driver $booking->getDriver();
  633.         if ($driver !== null) {
  634.             $driver->addRating($rating);
  635.         }
  636.         $this->em->flush();
  637.         return $this->json(['booking' => $this->serializeBooking($booking)]);
  638.     }
  639.     /**
  640.      * Leave the driver a tip once the tour is over.
  641.      *
  642.      * Like the fare, this is a record of intent rather than a card charge — no
  643.      * gateway is wired up yet, so a "card" tip is settled the same way the fare
  644.      * is. The guards mirror the rating endpoint: passenger only, completed
  645.      * only, once only.
  646.      */
  647.     #[Route('/bookings/{reference}/tip'name'booking_tip'methods: ['POST'])]
  648.     #[IsGranted('ROLE_USER')]
  649.     public function tipBooking(
  650.         string $reference,
  651.         Request $request,
  652.         BookingRepository $bookings,
  653.     ): JsonResponse {
  654.         /** @var User $user */
  655.         $user $this->getUser();
  656.         if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {
  657.             return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
  658.         }
  659.         $booking $bookings->findOneByReference($reference);
  660.         if ($booking === null) {
  661.             return $this->json(['error' => 'Booking not found.'], Response::HTTP_NOT_FOUND);
  662.         }
  663.         // Only the passenger who took the tour may tip for it.
  664.         if ($booking->getUser()->getId() !== $user->getId()) {
  665.             return $this->json(['error' => 'Not your booking.'], Response::HTTP_FORBIDDEN);
  666.         }
  667.         if ($booking->getStatus() !== Booking::STATUS_COMPLETED) {
  668.             return $this->json(
  669.                 ['error' => 'You can only tip after the tour is finished.'],
  670.                 Response::HTTP_CONFLICT
  671.             );
  672.         }
  673.         if ($booking->getTipAmd() !== null) {
  674.             return $this->json(
  675.                 ['error' => 'You have already tipped for this tour.'],
  676.                 Response::HTTP_CONFLICT
  677.             );
  678.         }
  679.         $body   $this->decodeBody($request) ?? [];
  680.         $amount $body['amountAmd'] ?? 0;
  681.         if (!is_numeric($amount)) {
  682.             return $this->json(['error' => 'Please enter a tip amount.'], Response::HTTP_BAD_REQUEST);
  683.         }
  684.         $amount = (int) $amount;
  685.         if ($amount Booking::TIP_MIN_AMD || $amount Booking::TIP_MAX_AMD) {
  686.             return $this->json(
  687.                 [
  688.                     'error' => sprintf(
  689.                         'A tip must be between %d and %d AMD.',
  690.                         Booking::TIP_MIN_AMD,
  691.                         Booking::TIP_MAX_AMD
  692.                     ),
  693.                 ],
  694.                 Response::HTTP_BAD_REQUEST
  695.             );
  696.         }
  697.         $method = (string) ($body['method'] ?? Booking::PAYMENT_CASH);
  698.         if (!in_array($methodBooking::PAYMENT_METHODStrue)) {
  699.             return $this->json(['error' => 'Invalid payment method.'], Response::HTTP_BAD_REQUEST);
  700.         }
  701.         // A card tip needs one of this passenger's own cards behind it.
  702.         if ($method === Booking::PAYMENT_CARD) {
  703.             $card $this->em->getRepository(PaymentCard::class)->find((int) ($body['cardId'] ?? 0));
  704.             if ($card === null || $card->getUser()->getId() !== $user->getId()) {
  705.                 return $this->json(['error' => 'Card not found.'], Response::HTTP_NOT_FOUND);
  706.             }
  707.             if ($card->isExpired()) {
  708.                 return $this->json(['error' => 'That card has expired.'], Response::HTTP_BAD_REQUEST);
  709.             }
  710.         }
  711.         $booking->addTip($amount$method);
  712.         // Keep the driver's running total in step.
  713.         $driver $booking->getDriver();
  714.         if ($driver !== null) {
  715.             $driver->addTip($amount);
  716.         }
  717.         $this->em->flush();
  718.         return $this->json(['booking' => $this->serializeBooking($booking)]);
  719.     }
  720.     // ── Cards ────────────────────────────────────────────────────────────
  721.     #[Route('/cards'name'cards'methods: ['GET'])]
  722.     #[IsGranted('ROLE_USER')]
  723.     public function cards(PaymentCardRepository $cards): JsonResponse
  724.     {
  725.         /** @var User $user */
  726.         $user $this->getUser();
  727.         $out = [];
  728.         foreach ($cards->findForUser($user) as $card) {
  729.             $out[] = $this->serializeCard($card);
  730.         }
  731.         return $this->json(['cards' => $out]);
  732.     }
  733.     /**
  734.      * Save a card for later use.
  735.      *
  736.      * The full number is used only to work out the brand and the last four
  737.      * digits, and is then discarded — it is never written to the database. See
  738.      * the note on the PaymentCard entity.
  739.      */
  740.     #[Route('/cards'name'card_create'methods: ['POST'])]
  741.     #[IsGranted('ROLE_USER')]
  742.     public function createCard(Request $requestPaymentCardRepository $cards): JsonResponse
  743.     {
  744.         /** @var User $user */
  745.         $user $this->getUser();
  746.         if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {
  747.             return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
  748.         }
  749.         $body $this->decodeBody($request);
  750.         if ($body === null) {
  751.             return $this->json(['error' => 'Malformed JSON body.'], Response::HTTP_BAD_REQUEST);
  752.         }
  753.         try {
  754.             $card $this->buildCard($user$body$cards);
  755.         } catch (CheckoutException $e) {
  756.             return $this->json(['error' => $e->getMessage()], $e->statusCode);
  757.         }
  758.         $this->persistCard($card$cards);
  759.         $this->em->flush();
  760.         // The number went out of scope inside buildCard() and was never persisted.
  761.         return $this->json(['card' => $this->serializeCard($card)], Response::HTTP_CREATED);
  762.     }
  763.     /**
  764.      * Validate typed-in card details and build the record we keep of them.
  765.      *
  766.      * Shared by "add a card" on the account page and by the payment step of an
  767.      * order, where a guest types their card straight into checkout — both must
  768.      * apply the same rules and both must forget the number the same way. The
  769.      * card comes back unpersisted; the caller decides when it is written.
  770.      *
  771.      * @param array<string, mixed> $data
  772.      *
  773.      * @throws CheckoutException on a number, expiry or checksum we won't accept
  774.      */
  775.     private function buildCard(User $user, array $dataPaymentCardRepository $cards): PaymentCard
  776.     {
  777.         $number preg_replace('/\D/''', (string) ($data['number'] ?? '')) ?? '';
  778.         if (strlen($number) < 12 || strlen($number) > 19 || !$this->passesLuhn($number)) {
  779.             throw CheckoutException::invalid('That card number is not valid.');
  780.         }
  781.         $expMonth = (int) ($data['expMonth'] ?? 0);
  782.         $expYear  = (int) ($data['expYear'] ?? 0);
  783.         // Accept a two-digit year the way a card is printed.
  784.         if ($expYear && $expYear 100) {
  785.             $expYear += 2000;
  786.         }
  787.         if ($expMonth || $expMonth 12 || $expYear 2000 || $expYear 2100) {
  788.             throw CheckoutException::invalid('That expiry date is not valid.');
  789.         }
  790.         $card = new PaymentCard();
  791.         $card->setUser($user);
  792.         $card->setBrand(PaymentCard::detectBrand($number));
  793.         $card->setLast4(substr($number, -4));
  794.         $card->setHolderName($this->cleanString($data['holderName'] ?? null100));
  795.         $card->setExpMonth($expMonth);
  796.         $card->setExpYear($expYear);
  797.         if ($card->isExpired()) {
  798.             throw CheckoutException::invalid('That card has already expired.');
  799.         }
  800.         // A guest's user row does not exist yet, so there is nothing of theirs to
  801.         // query — their first card is, trivially, their first card.
  802.         $existing $user->getId() === null ? [] : $cards->findForUser($user);
  803.         // First card added becomes the default; after that, only if asked. The
  804.         // flag is only set here — clearing it on the others is left to whoever
  805.         // saves this card, so an order that never completes cannot leave a
  806.         // passenger with no default card at all.
  807.         $card->setIsDefault((bool) ($data['isDefault'] ?? false) || $existing === []);
  808.         return $card;
  809.         // $number goes out of scope here and was never persisted.
  810.     }
  811.     /**
  812.      * Save a card built by buildCard(), keeping the "only one default" rule.
  813.      * Assumes the caller flushes.
  814.      */
  815.     private function persistCard(PaymentCard $cardPaymentCardRepository $cards): void
  816.     {
  817.         if ($card->isDefault() && $card->getUser()->getId() !== null) {
  818.             $cards->clearDefaultForUser($card->getUser());
  819.         }
  820.         $this->em->persist($card);
  821.     }
  822.     #[Route('/cards/{id}'name'card_delete'methods: ['DELETE'])]
  823.     #[IsGranted('ROLE_USER')]
  824.     public function deleteCard(int $idRequest $request): JsonResponse
  825.     {
  826.         /** @var User $user */
  827.         $user $this->getUser();
  828.         if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {
  829.             return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
  830.         }
  831.         $card $this->em->getRepository(PaymentCard::class)->find($id);
  832.         if ($card === null || $card->getUser()->getId() !== $user->getId()) {
  833.             return $this->json(['error' => 'Card not found.'], Response::HTTP_NOT_FOUND);
  834.         }
  835.         $this->em->remove($card);
  836.         $this->em->flush();
  837.         return $this->json(['deleted' => true]);
  838.     }
  839.     /**
  840.      * Make one of the passenger's cards the default. Clears the flag on the
  841.      * others so only ever one holds it.
  842.      */
  843.     #[Route('/cards/{id}/default'name'card_default'methods: ['POST'])]
  844.     #[IsGranted('ROLE_USER')]
  845.     public function setDefaultCard(int $idRequest $requestPaymentCardRepository $cards): JsonResponse
  846.     {
  847.         /** @var User $user */
  848.         $user $this->getUser();
  849.         if (!$this->isCsrfTokenValid('taxi_api', (string) $request->headers->get('X-CSRF-Token'))) {
  850.             return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
  851.         }
  852.         $card $this->em->getRepository(PaymentCard::class)->find($id);
  853.         if ($card === null || $card->getUser()->getId() !== $user->getId()) {
  854.             return $this->json(['error' => 'Card not found.'], Response::HTTP_NOT_FOUND);
  855.         }
  856.         $cards->clearDefaultForUser($user$card);
  857.         $card->setIsDefault(true);
  858.         $this->em->flush();
  859.         return $this->json(['card' => $this->serializeCard($card)]);
  860.     }
  861.     // ── Serialization ────────────────────────────────────────────────────
  862.     private function serializeRoute(TourRoute $route): array
  863.     {
  864.         $points = [];
  865.         foreach ($route->getPoints() as $point) {
  866.             $location $point->getLocation();
  867.             // A stop with no coordinates cannot be drawn or routed through.
  868.             if ($location->getLatitude() === null || $location->getLongitude() === null) {
  869.                 continue;
  870.             }
  871.             $points[] = [
  872.                 'id'          => $location->getId(),
  873.                 'name'        => $location->getName(),
  874.                 'nameHy'      => $location->getNameHy(),
  875.                 'nameRu'      => $location->getNameRu(),
  876.                 'address'     => $location->getAddress(),
  877.                 'category'    => $location->getCategory(),
  878.                 'lat'         => (float) $location->getLatitude(),
  879.                 'lng'         => (float) $location->getLongitude(),
  880.                 'stopMinutes' => $point->getStopMinutes(),
  881.                 'sort'        => $point->getSortOrder(),
  882.             ];
  883.         }
  884.         return [
  885.             'id'              => $route->getId(),
  886.             'slug'            => $route->getSlug(),
  887.             'name'            => $route->getName(),
  888.             'nameHy'          => $route->getNameHy(),
  889.             'nameRu'          => $route->getNameRu(),
  890.             'description'     => $route->getDescription(),
  891.             'descriptionHy'   => $route->getDescriptionHy(),
  892.             'descriptionRu'   => $route->getDescriptionRu(),
  893.             'color'           => $route->getColor(),
  894.             'durationMinutes' => $route->getDurationMinutes(),
  895.             // priceAmd is the one-passenger price, i.e. what the route card
  896.             // shows "from". The full party-size table travels with it so the
  897.             // sheet can re-price as the passenger changes the head count
  898.             // without re-deriving the rules in JavaScript.
  899.             'priceAmd'        => $this->pricing->priceFor($route1),
  900.             'basePriceAmd'    => $this->pricing->basePrice($route),
  901.             'priceTable'      => $this->pricing->table($route),
  902.             'maxPassengers'   => TourPricing::MAX_PASSENGERS,
  903.             'stopCount'       => count($points),
  904.             'points'          => $points,
  905.             // The pre-resolved road, so the client draws streets rather than
  906.             // straight lines. Null until app:route:geometry has run.
  907.             'geometry'        => $route->getGeometry(),
  908.             'distanceMeters'  => $route->getDistanceMeters(),
  909.         ];
  910.     }
  911.     private function serializeTuk(Tuk $tuk, ?float $distance): array
  912.     {
  913.         $driver $tuk->getDriver();
  914.         return [
  915.             'id'       => $tuk->getId(),
  916.             'code'     => $tuk->getCode(),
  917.             'plate'    => $tuk->getPlateNumber(),
  918.             'color'    => $tuk->getColor(),
  919.             'status'   => $tuk->getStatus(),   // 'available' | 'busy'
  920.             'seats'    => $tuk->getSeats(),
  921.             'photo'    => $tuk->getPhoto(),
  922.             'lat'      => (float) $tuk->getLatitude(),
  923.             'lng'      => (float) $tuk->getLongitude(),
  924.             'heading'  => $tuk->getHeading(),
  925.             'distance' => $distance === null null round($distance),
  926.             'eta'      => $distance === null null max(1, (int) ceil($distance self::TUK_SPEED_M_PER_MIN)),
  927.             'driver'   => $driver === null null $this->serializeDriver($driver),
  928.         ];
  929.     }
  930.     private function serializeDriver(Driver $driver): array
  931.     {
  932.         return [
  933.             'id'          => $driver->getId(),
  934.             'name'        => $driver->getName(),
  935.             'photo'       => $driver->getPhoto(),
  936.             'rating'      => $driver->getRating(),
  937.             'ratingCount' => $driver->getRatingCount(),
  938.             'tripCount'   => $driver->getTripCount(),
  939.             'languages'   => $driver->getLanguages(),
  940.         ];
  941.     }
  942.     private function serializeBooking(Booking $booking): array
  943.     {
  944.         $tuk    $booking->getTuk();
  945.         $driver $booking->getDriver();
  946.         $card   $booking->getPaymentCard();
  947.         $route  $booking->getTourRoute();
  948.         return [
  949.             'reference'     => $booking->getReference(),
  950.             'status'        => $booking->getStatus(),
  951.             'statusLabel'   => $booking->getStatusLabel(),
  952.             'priceAmd'      => $booking->getPriceAmd(),
  953.             'passengers'    => $booking->getPassengers(),
  954.             'paymentMethod' => $booking->getPaymentMethod(),
  955.             'paymentStatus' => $booking->getPaymentStatus(),
  956.             'cancellable'   => $booking->isCancellable(),
  957.             'comment'       => $booking->getComment(),
  958.             'rating'        => $booking->getRating(),
  959.             'rateable'      => $booking->getStatus() === Booking::STATUS_COMPLETED && $booking->getRating() === null,
  960.             'tipAmd'        => $booking->getTipAmd(),
  961.             'tipMethod'     => $booking->getTipMethod(),
  962.             'tippable'      => $booking->isTippable(),
  963.             'tipPresets'    => Booking::TIP_PRESETS_AMD,
  964.             'tipMin'        => Booking::TIP_MIN_AMD,
  965.             'tipMax'        => Booking::TIP_MAX_AMD,
  966.             'createdAt'     => $booking->getCreatedAt()->format(\DATE_ATOM),
  967.             'pickup'        => [
  968.                 'lat'     => $booking->getPickupLatitude() === null null : (float) $booking->getPickupLatitude(),
  969.                 'lng'     => $booking->getPickupLongitude() === null null : (float) $booking->getPickupLongitude(),
  970.                 'address' => $booking->getPickupAddress(),
  971.             ],
  972.             'route' => [
  973.                 'id'              => $route->getId(),
  974.                 'slug'            => $route->getSlug(),
  975.                 'name'            => $route->getName(),
  976.                 'nameHy'          => $route->getNameHy(),
  977.                 'nameRu'          => $route->getNameRu(),
  978.                 'color'           => $route->getColor(),
  979.                 'durationMinutes' => $route->getDurationMinutes(),
  980.             ],
  981.             'card' => $card === null null $this->serializeCard($card),
  982.             'tuk'  => $tuk === null null : [
  983.                 'id'      => $tuk->getId(),
  984.                 'code'    => $tuk->getCode(),
  985.                 'plate'   => $tuk->getPlateNumber(),
  986.                 'color'   => $tuk->getColor(),
  987.                 'seats'   => $tuk->getSeats(),
  988.                 // Live position, so the sheet can follow the tuk in.
  989.                 'lat'     => $tuk->getLatitude() === null null : (float) $tuk->getLatitude(),
  990.                 'lng'     => $tuk->getLongitude() === null null : (float) $tuk->getLongitude(),
  991.                 'heading' => $tuk->getHeading(),
  992.                 'online'  => $tuk->isOnline(),
  993.             ],
  994.             'driver' => $driver === null null array_merge(
  995.                 $this->serializeDriver($driver),
  996.                 // The driver's phone number is personal data, so it is released
  997.                 // only while they are actually on this job: not while the order
  998.                 // is still waiting to be accepted, and not once it is finished
  999.                 // or cancelled.
  1000.                 ['phone' => $this->driverPhoneFor($booking)]
  1001.             ),
  1002.         ];
  1003.     }
  1004.     private function serializeCard(PaymentCard $card): array
  1005.     {
  1006.         return [
  1007.             'id'       => $card->getId(),
  1008.             'brand'    => $card->getBrand(),
  1009.             'label'    => $card->getBrandLabel(),
  1010.             'last4'    => $card->getLast4(),
  1011.             'masked'   => $card->getMaskedNumber(),
  1012.             'expMonth' => $card->getExpMonth(),
  1013.             'expYear'  => $card->getExpYear(),
  1014.             'expired'  => $card->isExpired(),
  1015.             'default'  => $card->isDefault(),
  1016.         ];
  1017.     }
  1018.     // ── Helpers ──────────────────────────────────────────────────────────
  1019.     /**
  1020.      * The driver's phone, but only while they are en route to or driving this
  1021.      * passenger. Null at every other point in the booking's life.
  1022.      */
  1023.     private function driverPhoneFor(Booking $booking): ?string
  1024.     {
  1025.         $driver $booking->getDriver();
  1026.         if ($driver === null) {
  1027.             return null;
  1028.         }
  1029.         $active = [
  1030.             Booking::STATUS_ACCEPTED,
  1031.             Booking::STATUS_ARRIVED,
  1032.             Booking::STATUS_IN_PROGRESS,
  1033.         ];
  1034.         return in_array($booking->getStatus(), $activetrue)
  1035.             ? $driver->getUser()->getPhone()
  1036.             : null;
  1037.     }
  1038.     /**
  1039.      * The passenger who ordered, or the driver assigned to it.
  1040.      */
  1041.     private function canSee(Booking $booking): bool
  1042.     {
  1043.         /** @var User|null $user */
  1044.         $user $this->getUser();
  1045.         if ($user === null) {
  1046.             return false;
  1047.         }
  1048.         if ($booking->getUser()->getId() === $user->getId()) {
  1049.             return true;
  1050.         }
  1051.         $driver $booking->getDriver();
  1052.         return $driver !== null && $driver->getUser()->getId() === $user->getId();
  1053.     }
  1054.     /**
  1055.      * @return array<string, mixed>|null null when the body is not a JSON object
  1056.      */
  1057.     private function decodeBody(Request $request): ?array
  1058.     {
  1059.         $data json_decode($request->getContent(), true);
  1060.         return is_array($data) ? $data null;
  1061.     }
  1062.     private function cleanString(mixed $valueint $maxLength): ?string
  1063.     {
  1064.         if (!is_string($value)) {
  1065.             return null;
  1066.         }
  1067.         $value trim($value);
  1068.         if ($value === '') {
  1069.             return null;
  1070.         }
  1071.         return mb_substr($value0$maxLength);
  1072.     }
  1073.     /**
  1074.      * Turn the client's date/time string into an immutable date, or null if it
  1075.      * cannot be understood. Accepts the datetime-local shape the form sends
  1076.      * ("2026-07-18T10:00") and anything else strtotime handles.
  1077.      */
  1078.     private function parseScheduledAt(mixed $value): ?\DateTimeImmutable
  1079.     {
  1080.         if (!is_string($value) || trim($value) === '') {
  1081.             return null;
  1082.         }
  1083.         try {
  1084.             return new \DateTimeImmutable($value);
  1085.         } catch (\Exception) {
  1086.             return null;
  1087.         }
  1088.     }
  1089.     /**
  1090.      * Turn the ordered list of location ids the passenger picked into the stops
  1091.      * stored on the reservation.
  1092.      *
  1093.      * Everything about a stop — its name and its coordinates — is read from the
  1094.      * Location row here. The request body contributes nothing but the id and
  1095.      * the order, so a client cannot invent a place, move an existing one, or
  1096.      * route the tour somewhere we do not operate.
  1097.      *
  1098.      * @return array<int, array{locationId: int, lat: float, lng: float, label: string}>|null
  1099.      *         null when the selection is unusable
  1100.      */
  1101.     private function resolveCustomStops(mixed $rawLocationRepository $locations): ?array
  1102.     {
  1103.         if (!is_array($raw)) {
  1104.             return null;
  1105.         }
  1106.         $out      = [];
  1107.         $previous null;
  1108.         foreach ($raw as $id) {
  1109.             if (!is_numeric($id)) {
  1110.                 continue;
  1111.             }
  1112.             $id = (int) $id;
  1113.             // The same place twice in a row is not a second stop.
  1114.             if ($id === $previous) {
  1115.                 continue;
  1116.             }
  1117.             $location $locations->find($id);
  1118.             if ($location === null
  1119.                 || !$location->isActive()
  1120.                 || $location->getLatitude() === null
  1121.                 || $location->getLongitude() === null
  1122.             ) {
  1123.                 // An unknown or unmappable stop is a broken request, not one to
  1124.                 // silently shorten — the passenger would get a different tour
  1125.                 // from the one they asked for.
  1126.                 return null;
  1127.             }
  1128.             $out[] = [
  1129.                 'locationId' => $id,
  1130.                 'lat'        => round((float) $location->getLatitude(), 6),
  1131.                 'lng'        => round((float) $location->getLongitude(), 6),
  1132.                 'label'      => $location->getName(),
  1133.             ];
  1134.             $previous $id;
  1135.             if (count($out) >= TourReservation::CUSTOM_MAX_POINTS) {
  1136.                 break;
  1137.             }
  1138.         }
  1139.         return count($out) >= TourReservation::CUSTOM_MIN_POINTS $out null;
  1140.     }
  1141.     private function serializeReservation(TourReservation $reservation): array
  1142.     {
  1143.         $route $reservation->getTourRoute();
  1144.         return [
  1145.             'reference'   => $reservation->getReference(),
  1146.             'kind'        => $reservation->getKind(),
  1147.             'status'      => $reservation->getStatus(),
  1148.             'statusLabel' => $reservation->getStatusLabel(),
  1149.             'scheduledAt' => $reservation->getScheduledAt()->format(\DATE_ATOM),
  1150.             'passengers'  => $reservation->getPassengers(),
  1151.             'priceAmd'    => $reservation->getPriceAmd(),
  1152.             'pickupAddress' => $reservation->getPickupAddress(),
  1153.             'note'        => $reservation->getNote(),
  1154.             'tourName'    => $reservation->getTourName(),
  1155.             'color'       => $reservation->getTourColor(),
  1156.             'route'       => $route === null null : [
  1157.                 'id'     => $route->getId(),
  1158.                 'name'   => $route->getName(),
  1159.                 'nameHy' => $route->getNameHy(),
  1160.                 'nameRu' => $route->getNameRu(),
  1161.                 'color'  => $route->getColor(),
  1162.             ],
  1163.             // Only a drawn tour carries these; for one of our lines the client
  1164.             // already has the geometry from /api/routes.
  1165.             'custom' => !$reservation->isCustom() ? null : [
  1166.                 'title'          => $reservation->getCustomTitle(),
  1167.                 'points'         => $reservation->getCustomPoints() ?? [],
  1168.                 'geometry'       => $reservation->getCustomGeometry(),
  1169.                 'distanceMeters' => $reservation->getCustomDistanceMeters(),
  1170.             ],
  1171.         ];
  1172.     }
  1173.     /**
  1174.      * Luhn check digit — catches typos before we bother the passenger.
  1175.      */
  1176.     private function passesLuhn(string $number): bool
  1177.     {
  1178.         $sum    0;
  1179.         $double false;
  1180.         for ($i strlen($number) - 1$i >= 0$i--) {
  1181.             $digit = (int) $number[$i];
  1182.             if ($double) {
  1183.                 $digit *= 2;
  1184.                 if ($digit 9) {
  1185.                     $digit -= 9;
  1186.                 }
  1187.             }
  1188.             $sum   += $digit;
  1189.             $double = !$double;
  1190.         }
  1191.         return $sum 10 === 0;
  1192.     }
  1193. }