src/Bus/BookingController.php line 309

Open in your IDE?
  1. <?php
  2. namespace App\Bus;
  3. use App\Entity\Bus\BookingMpesa;
  4. use App\Entity\Bus\BookingSeat;
  5. use App\Entity\Bus\BusLayout;
  6. use App\Entity\Bus\Reservation;
  7. use App\Entity\Bus\RouteFareRule;
  8. use App\Entity\Bus\Seat;
  9. use App\Entity\Bus\Stop;
  10. use App\Entity\Bus\Trip;
  11. use App\Entity\Bus\TripVehicle;
  12. use App\Entity\Mpesa;
  13. use App\Entity\MpesaAuth;
  14. use App\Entity\MpesaPayment;
  15. use App\Entity\MpesaPaymentRequest;
  16. use App\Entity\MpesaResponse;
  17. use App\Entity\User;
  18. use App\Entity\UserStation;
  19. use App\Entity\Vehicle;
  20. use App\Entity\WayBill;
  21. use App\Form\Bus\BookingType;
  22. use App\Form\Bus\ReserveType;
  23. use App\Form\Bus\RouteeType;
  24. use App\Form\Bus\TripType;
  25. use App\Service\BusTicketSmsSender;
  26. use App\Service\SeatHoldReleaser;
  27. use App\Service\TicketPdfBuilder;
  28. use DateTime;
  29. use Doctrine\DBAL\Exception;
  30. use Doctrine\Persistence\ManagerRegistry;
  31. use Doctrine\Persistence\ObjectManager;
  32. use JMS\Serializer\SerializationContext;
  33. use JMS\Serializer\SerializerBuilder;
  34. use PhpOffice\PhpSpreadsheet\Worksheet\AutoFilter\Column\Rule;
  35. use Psr\Log\LoggerInterface;
  36. use Sasedev\MpdfBundle\Factory\MpdfFactory;
  37. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  38. use Symfony\Component\Form\FormError;
  39. use Symfony\Component\HttpFoundation\JsonResponse;
  40. use Symfony\Component\HttpFoundation\Request;
  41. use Symfony\Component\HttpFoundation\RequestStack;
  42. use Symfony\Component\HttpFoundation\Response;
  43. use Symfony\Component\Routing\Annotation\Route;
  44. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  45. class BookingController extends AbstractController{
  46. private ManagerRegistry $registry;
  47. private ObjectManager $entityManager;
  48. private RequestStack $requestStack;
  49. private SeatHoldReleaser $seatHoldReleaser;
  50. private BusTicketSmsSender $ticketSmsSender;
  51. public function __construct(ManagerRegistry $registry, RequestStack $requestStack, SeatHoldReleaser $seatHoldReleaser, BusTicketSmsSender $ticketSmsSender)
  52. {
  53. $this->entityManager = $registry->getManager();
  54. $this->requestStack = $requestStack;
  55. $this->seatHoldReleaser = $seatHoldReleaser;
  56. $this->ticketSmsSender = $ticketSmsSender;
  57. }
  58. /** @Route("/routes", name="routesHome") */
  59. public function routesHome(Request $request)
  60. {
  61. $em = $this->entityManager;
  62. /** @var UserStation $userTown */
  63. $userTown = $em->getRepository(UserStation::class)->findOneBy([
  64. 'user' => $this->getUser(),
  65. 'isActive' => true
  66. ], ['id' => 'DESC']);
  67. $route = new \App\Entity\Bus\Route();
  68. $route->setCreatedAt(new DateTime());
  69. $route->setCreatedBy($this->getUser());
  70. $routeForm = $this->createForm(RouteeType::class, $route);
  71. $routeForm->handleRequest($request);
  72. if ($routeForm->isSubmitted() && $routeForm->isValid()) {
  73. dump($route);
  74. $this->entityManager->persist($route);
  75. $this->entityManager->flush();
  76. return $this->redirectToRoute('routesHome');
  77. }
  78. /** @var \App\Entity\Bus\Route[] $routes */
  79. $routes = $this->entityManager->getRepository(\App\Entity\Bus\Route::class)->findAll();
  80. return $this->render('bus/routes.html.twig', [
  81. 'user_town' => $userTown,
  82. 'routes' => $routes,
  83. 'routeForm' => $routeForm->createView()
  84. ]);
  85. }
  86. /** @Route("/routes/{route}/edit", name="routesEdit") */
  87. public function routesEdit(Request $request, \App\Entity\Bus\Route $route)
  88. {
  89. $em = $this->entityManager;
  90. /** @var UserStation $userTown */
  91. $userTown = $em->getRepository(UserStation::class)->findOneBy([
  92. 'user' => $this->getUser(),
  93. 'isActive' => true
  94. ], ['id' => 'DESC']);
  95. $routeForm = $this->createForm(RouteeType::class, $route);
  96. $routeForm->handleRequest($request);
  97. if ($routeForm->isSubmitted() && $routeForm->isValid()) {
  98. // $route->setStops($route->getStops());
  99. $this->entityManager->persist($route);
  100. $this->entityManager->flush();
  101. $this->addFlash('success', 'Route updated successfully');
  102. return $this->redirectToRoute('routesHome');
  103. }
  104. /** @var \App\Entity\Bus\Route[] $routes */
  105. $routes = $this->entityManager->getRepository(\App\Entity\Bus\Route::class)->findAll();
  106. return $this->render('bus/routes_edit.html.twig', [
  107. 'user_town' => $userTown,
  108. 'routes' => $routes,
  109. 'routeForm' => $routeForm->createView()
  110. ]);
  111. }
  112. /** @Route("/", name="tripsHome") */
  113. public function tripsHome(Request $request)
  114. {
  115. $em = $this->entityManager;
  116. $date = $request->get('trip_date');
  117. $today = new \DateTimeImmutable();
  118. if (!$date) {
  119. $date = $today->format('Y-m-d');
  120. }
  121. /** @var UserStation $userTown */
  122. $userTown = $em->getRepository(UserStation::class)->findOneBy([
  123. 'user' => $this->getUser(),
  124. 'isActive' => true
  125. ], ['id' => 'DESC']);
  126. $stationId = $userTown->getStation()->getId();
  127. // MOMBASA and MERU scenario 1
  128. /* if a station origin is in current station show bus route */
  129. // $sql = "SELECT a.id, a.route_name FROM bus_route a WHERE origin = {$stationId}";
  130. // EMBU scenario 2
  131. /* if a station is a stop in a main route and not the destination */
  132. $sql = "SELECT DISTINCT a.id, a.route_name FROM bus_route a
  133. LEFT JOIN bus_stop b ON b.route_id = a.id AND b.route_type = 'MAIN' AND b.station_id = {$stationId}
  134. WHERE (b.station_id = {$stationId} OR a.origin = {$stationId})
  135. AND a.destination != {$stationId}";
  136. $conn = $this->entityManager->getConnection();
  137. $availableRoutes = null;
  138. try {
  139. $stmt = $conn->prepare($sql);
  140. $availableRoutes = $stmt->executeQuery()->fetchAllAssociative();
  141. } catch (\Exception $e) {
  142. }
  143. /** @var Trip[] $trips */
  144. $trips = $this->entityManager->getRepository(Trip::class)->findActiveTrips($date, $userTown->getStation()->getId(), 'MAIN');
  145. if($trips){
  146. return $this->render('bus/trips.html.twig', [
  147. 'user_town' => $userTown,
  148. 'trips' => $trips,
  149. 'date' => $date
  150. ]);
  151. }else {
  152. foreach ($availableRoutes as $index => $availableRoute) {
  153. $route = $em->getRepository(\App\Entity\Bus\Route::class)->findOneBy([
  154. 'id' => $availableRoute['id']
  155. ]);
  156. $this->addTrip($route, $date);
  157. }
  158. }
  159. /** @var Trip[] $trips */
  160. $trips = $this->entityManager->getRepository(Trip::class)->findActiveTrips($date, $userTown->getStation()->getId(), 'MAIN');
  161. return $this->render('bus/trips.html.twig', [
  162. 'user_town' => $userTown,
  163. 'trips' => $trips,
  164. 'date' => $date
  165. ]);
  166. }
  167. /** @Route("/trips/new", name="newTrip") */
  168. public function newTrip(Request $request)
  169. {
  170. $em = $this->entityManager;
  171. /** @var UserStation $userTown */
  172. $userTown = $em->getRepository(UserStation::class)->findOneBy([
  173. 'user' => $this->getUser(),
  174. 'isActive' => true
  175. ], ['id' => 'DESC']);
  176. $trip = new Trip();
  177. $tripForm = $this->createForm(TripType::class, $trip);
  178. $tripForm->handleRequest($request);
  179. if ($tripForm->isSubmitted() && $tripForm->isValid()) {
  180. $date = $trip->getTripDate();
  181. $d = $date->format('Y-m-d');
  182. $sql = "SELECT count(*) total FROM bus_trip a WHERE a.route_id = {$trip->getRoute()->getId()}
  183. AND DATE(a.trip_date) = CAST('{$d}' as date)";
  184. $conn = $this->entityManager->getConnection();
  185. $stmt = $conn->prepare($sql);
  186. $thisDaysTrips = $stmt->executeQuery()->fetchAssociative();
  187. $sameRouteTrips = $thisDaysTrips['total'];
  188. $trip->setTripIndex($sameRouteTrips + 1);
  189. $trip->setCreatedBy($this->getUser());
  190. $trip->setCreatedAt(new DateTime());
  191. /** @var Vehicle $vehicle */
  192. $vehicle = $tripForm->get('vehicle')->getData();
  193. $seats = [];
  194. for ($i = 1; $i <= $vehicle->getPassengerCapacity(); $i++) {
  195. $seat = new Seat();
  196. $seat->setStatus('AVAILABLE');
  197. $seat->setSeatNumber($i);
  198. $seat->setTrip($trip);
  199. $seat->setIsBooked(false);
  200. $seat->setCreatedAt(new DateTime());
  201. $seats[] = $seat;
  202. }
  203. $trip->setSeats($seats);
  204. $tripVehicle = new TripVehicle();
  205. $tripVehicle->setCreatedAt(new DateTime());
  206. $tripVehicle->setTrip($trip);
  207. $tripVehicle->setCreatedBy($this->getUser());
  208. $tripVehicle->setIsCancelled(false);
  209. $tripVehicle->setVehicle($vehicle);
  210. // $this->entityManager->persist($tripVehicle);
  211. // $this->entityManager->persist($trip);
  212. // $this->entityManager->flush();
  213. return $this->redirectToRoute('tripsHome');
  214. }
  215. return $this->render('bus/new_trip.html.twig', [
  216. 'tripForm' => $tripForm->createView(),
  217. 'user_town' => $userTown
  218. ]);
  219. }
  220. /** @Route("/trip/{trip}", name="viewTrip") */
  221. public function bookingTrip(Request $request, Trip $trip)
  222. {
  223. $currentSeat = $request->get('currentSeat') ? $request->get('currentSeat') : -1;
  224. $layout = $this->entityManager->getRepository(BusLayout::class)->findOneBy(['isDefault' => true]);
  225. $doorRow = $layout ? ($layout->getDoorRow() ?? 0) : 0;
  226. $lastRow = $layout ? max(0, $layout->getNumRows() - 1) : 12;
  227. $rows = 0;
  228. /** @var UserStation $userTown */
  229. $userTown = $this->entityManager->getRepository(UserStation::class)->findOneBy([
  230. 'user' => $this->getUser(),
  231. 'isActive' => true
  232. ], ['id' => 'DESC']);
  233. // dump($userTown);
  234. $stop = $this->entityManager->getRepository(Stop::class)->findCurrentStop($trip->getRoute(), $userTown->getStation());
  235. // dump($stop);
  236. // die;
  237. $currentStop = null;
  238. if ($stop) {
  239. $currentStop = $this->entityManager->getRepository(Stop::class)->findOneBy([
  240. 'id' => $stop['id']
  241. ]);
  242. }
  243. if (!$currentStop) {
  244. return $this->redirectToRoute('tripsHome');
  245. }
  246. // Self-heal any abandoned online holds before rendering, same as
  247. // the customer-facing seat map (BusApi\TripController::trip()) -
  248. // otherwise a seat whose hold expired minutes ago would still show
  249. // here as reserved until someone else happens to try booking it.
  250. $releasedCount = $this->seatHoldReleaser->releaseAllStaleForTrip($trip);
  251. if ($releasedCount > 0) {
  252. $this->entityManager->flush();
  253. }
  254. $seats = $this->entityManager->getRepository(BookingSeat::class)->findAllSeats($trip, $currentStop);
  255. // dump($seats);
  256. // dump('currentSeat',$currentSeat);
  257. return $this->render('bus/booking.html.twig', [
  258. 'seats' => $seats,
  259. 'rows' => $rows,
  260. 'doorRow' => $doorRow,
  261. 'lastRow' => $lastRow,
  262. 'workingSeat' => $currentSeat,
  263. 'trip' => $trip,
  264. 'staleHoldMinutes' => SeatHoldReleaser::staleHoldMinutes(),
  265. ]);
  266. }
  267. /** @Route("/trip/{trip}/json", name="viewJsonTrip") */
  268. public function getTripSeatsJson(Request $request, Trip $trip)
  269. {
  270. $context = new SerializationContext();
  271. $context->setSerializeNull(true);
  272. $page = $request->request->get('page') > 1 ? $request->request->get('page') : 1;
  273. $rows = $request->request->get('rows') > 1 ? $request->request->get('rows') : 50;
  274. $offset = ($page - 1) * $rows;
  275. $serializer = SerializerBuilder::create()->build();
  276. $filterRules = $request->request->get('filterRules');
  277. // Same self-heal as bookingTrip() above - this is the datagrid's
  278. // own independent data source (initial load, reload, refilter all
  279. // hit this action), so it needs the same check rather than relying
  280. // on the full-page render to have already done it once.
  281. $releasedCount = $this->seatHoldReleaser->releaseAllStaleForTrip($trip);
  282. if ($releasedCount > 0) {
  283. $this->entityManager->flush();
  284. }
  285. $seats = $this->entityManager->getRepository(BookingSeat::class)->findAllTripSeats($filterRules, $offset, $rows, $trip);
  286. return new JsonResponse($seats);
  287. }
  288. /** @Route("/trip/{trip}/manifest/pdf", name="viewTripManifest") */
  289. public function getManifestBySearch(Request $request, Trip $trip, MpdfFactory $mpdfFactory)
  290. {
  291. $context = new SerializationContext();
  292. $context->setSerializeNull(true);
  293. $page = $request->request->get('page') > 1 ? $request->request->get('page') : 1;
  294. $rows = $request->request->get('rows') > 1 ? $request->request->get('rows') : 50;
  295. $offset = ($page - 1) * $rows;
  296. $filterRules = $request->request->get('filterRules');
  297. $seats = $this->entityManager->getRepository(BookingSeat::class)->findAllTripSeats($filterRules, $offset, $rows, $trip);
  298. $data = $this->jsonTestData();
  299. // dump($fontData);die;
  300. $mPdf = $mpdfFactory->createMpdfObject([
  301. 'format' => [80, 500],
  302. 'margin_header' => 5,
  303. 'margin_footer' => 5,
  304. 'margin_left' => 5,
  305. 'margin_right' => 5,
  306. 'margin_top' => 0,
  307. 'margin_bottom' => 0,
  308. 'autoPageBreak' => false,
  309. 'orientation' => 'P'
  310. ]);
  311. $date = new DateTime();
  312. $time = $date->getTimestamp();
  313. try {
  314. $receiptType = 'MANIFEST';
  315. $logoImage = file_get_contents('../public/bus/bus_1.png');
  316. $logoBase64 = base64_encode($logoImage);
  317. $mPdf->WriteHTML($this->renderView('bus/manifest/manifest.html.twig', [
  318. 'passengers' => $seats,
  319. 'trip' => $trip,
  320. 'busLogo' => $logoBase64
  321. ]));
  322. $file = "../manifests/manifest_.pdf";
  323. // return $MpdfFactory->createDownloadResponse($mPdf, "ticket_{$ticketId}.pdf", Response::HTTP_OK, ["Set-Cookie", "fileDownload=true; path=/"]);
  324. return $mpdfFactory->createInlineResponse($mPdf, $file);
  325. } catch (\Exception $e) {
  326. return new Response($e->getMessage(), Response::HTTP_BAD_REQUEST);
  327. }
  328. // return new JsonResponse($seats);
  329. }
  330. /** @Route("/trip/{trip}/stops", name="viewTripStopsSearchCombo") */
  331. public function getTripStops(Trip $trip)
  332. {
  333. $em = $this->entityManager;
  334. /** @var \App\Entity\Bus\Route $route */
  335. $route = $em->getRepository(\App\Entity\Bus\Route::class)->findOneBy([
  336. 'id' => $trip->getRoute()->getId()
  337. ]);
  338. $sql = "SELECT a.id,b.station_name FROM bus_stop a
  339. JOIN station b ON b.id = a.station_id
  340. WHERE a.route_id = {$route->getId()}";
  341. $conn = $em->getConnection();
  342. $stmt = $conn->prepare($sql);
  343. $stops = $stmt->executeQuery()->fetchAllAssociative();
  344. array_push($stops, ['id' => '', 'station_name' => 'ALL STOPS']);
  345. return new JsonResponse($stops);
  346. }
  347. /** @Route("/trip/{trip}/seat/{seat}", name="viewTripSeat") */
  348. public function bookingTripSeat(Request $request, $seat, Trip $trip)
  349. {
  350. $em = $this->entityManager;
  351. /** @var Seat $busSeat */
  352. $busSeat = $em->getRepository(Seat::class)->findOneBy([
  353. 'trip' => $trip,
  354. 'seatNumber' => $seat
  355. ]);
  356. $seatBooking = $em->getRepository(BookingSeat::class)->findOneBy([
  357. 'seat' => $busSeat
  358. ]);
  359. $bookingUserStation = null;
  360. if ($seatBooking) {
  361. /** @var UserStation $userTown */
  362. $bookingUserStation = $this->entityManager->getRepository(UserStation::class)->findOneBy([
  363. 'user' => $seatBooking->getCreatedBy(),
  364. 'isActive' => true
  365. ], ['id' => 'DESC']);
  366. }
  367. /** @var User $user */
  368. $user = $this->getUser();
  369. /** @var UserStation $userStation */
  370. $userStation = $em->getRepository(UserStation::class)->findOneBy([
  371. 'user' => $user,
  372. 'isActive' => true
  373. ]);
  374. $mpesaAuth = $em->getRepository(MpesaAuth::class)->findOneBy([
  375. 'station' => $userStation->getStation(),
  376. 'shortCodeType' => 'IMANI'
  377. ]);
  378. if (!$mpesaAuth) {
  379. $mpesaAuth = $em->getRepository(MpesaAuth::class)->findOneBy([
  380. 'station' => 2,
  381. 'shortCodeType' => 'IMANI'
  382. ]);
  383. }
  384. $reserve = $em->getRepository(Reservation::class)->findOneBy([
  385. 'seat' => $busSeat
  386. ]);
  387. $booking = new BookingSeat();
  388. $booking->setCreatedBy($this->getUser());
  389. $booking->setCreatedAt(new DateTime());
  390. $booking->setSeat($busSeat);
  391. $defaultFare = $this->getDatabaseFare($trip);
  392. if ($defaultFare > 0) {
  393. $booking->setAmount($defaultFare);
  394. }
  395. // if ($bookingUserStation) {
  396. // $booking->setDepartureTime($bookingUserStation->getDepartureTime());
  397. // }
  398. $bookingForm = $this->createForm(BookingType::class, $booking, [
  399. 'trip' => $trip,
  400. 'shortCode' => $mpesaAuth->getPaybill(),
  401. 'action' => $this->generateUrl('viewTripSeat', ['seat' => $seat, 'trip' => $trip->getId()])
  402. ]);
  403. $bookingForm->handleRequest($request);
  404. if ($bookingForm->isSubmitted()) {
  405. $dbFare = $this->getDatabaseFare($trip, $booking->getOrigin(), $booking->getDestination());
  406. if ($dbFare > 0) {
  407. $booking->setAmount($dbFare);
  408. }
  409. dump($request->getMethod());
  410. if ($bookingForm->has('mpesa')) {
  411. if ($bookingForm->get('mpesa') && $bookingForm->get('mpesa')->getData()) {
  412. /** @var Mpesa $mpesa */
  413. $mpesa = $bookingForm->get('mpesa')->getData();
  414. if ($booking->getPaidVia() == 'MPESA' || $booking->getPaidVia() == 'MPESA_EXPRESS') {
  415. dump($mpesa);
  416. if ($booking->getAmount() > $mpesa->getTransactionAmount()) {
  417. $bookingForm->get('amount')->addError(new FormError('Mpesa amount not enough'));
  418. }
  419. } else if ($booking->getPaidVia() == 'CASH_MPESA') {
  420. if ($mpesa->getTransactionAmount() >= $booking->getAmount()) {
  421. $bookingForm->get('amount')->addError(new FormError('Please check the amounts'));
  422. }
  423. }
  424. }
  425. }
  426. if ($bookingForm->isValid()) {
  427. if ($bookingForm->has('mpesa') && $bookingForm->get('mpesa') && $bookingForm->get('mpesa')->getData()) {
  428. // dump($bookingForm->get('mpesa')->getData());
  429. /** @var Mpesa $mpesa */
  430. $mpesa = $bookingForm->get('mpesa')->getData();
  431. $bookingMpesa = new BookingMpesa();
  432. $bookingMpesa->setBooking($booking);
  433. $bookingMpesa->setCreatedAt(new DateTime());
  434. $bookingMpesa->setCreatedBy($this->getUser());
  435. $bookingMpesa->setAmount($booking->getAmount());
  436. $bookingMpesa->setBalance(0);
  437. $bookingMpesa->setMpesa($mpesa);
  438. $mpesa->setIsUsed(true);
  439. $this->entityManager->persist($bookingMpesa);
  440. }
  441. $departureTime = $booking->getOrigin()->getDepartureTime();
  442. $eta = $booking->getOrigin()->getEta();
  443. $booking->setDepartureTime($departureTime);
  444. $booking->setEta($eta);
  445. $booking->getSeat()->setStatus('booked');
  446. $this->entityManager->persist($booking);
  447. $this->entityManager->flush();
  448. $this->ticketSmsSender->sendForSeat($booking);
  449. // return $this->redirectToRoute("@viewTrip?currentSeat={$seat}", ['trip' => $trip->getId()]);
  450. return $this->redirect("/bus/trip/{$trip->getId()}?currentSeat={$seat}");
  451. }
  452. }
  453. return $this->render('bus/seat_detail.html.twig', [
  454. 'seat' => $busSeat,
  455. 'trip' => $trip,
  456. 'booking' => $seatBooking,
  457. 'bookingForm' => $bookingForm->createView(),
  458. 'bookingStation' => $bookingUserStation,
  459. 'reservation' => $reserve,
  460. 'user_station' => $userStation
  461. ]);
  462. }
  463. /** @Route("/trip/{trip}/seat/{seat}/book_reserve_opt", name="viewReserveBookOpt") */
  464. public function bookingReserveOption(Request $request, $seat, Trip $trip)
  465. {
  466. $em = $this->entityManager;
  467. /** @var Seat $busSeat */
  468. $busSeat = $em->getRepository(Seat::class)->findOneBy([
  469. 'trip' => $trip,
  470. 'seatNumber' => $seat
  471. ]);
  472. $reserve = $em->getRepository(Reservation::class)->findOneBy([
  473. 'seat' => $busSeat,
  474. 'canceledAt' => null
  475. ]);
  476. $seatBooking = $em->getRepository(BookingSeat::class)->findOneBy([
  477. 'seat' => $busSeat,
  478. ]);
  479. if ($seatBooking || $reserve) {
  480. return $this->redirectToRoute('viewTripSeat', ['trip' => $trip->getId(), 'seat' => $busSeat->getSeatNumber()]);
  481. }
  482. return $this->render('bus/book_reserve.html.twig', [
  483. 'seat' => $busSeat,
  484. 'trip' => $trip,
  485. 'booking' => $seatBooking
  486. ]);
  487. }
  488. /** @Route("/trip/{trip}/seat/{seat}/cancel_reservation", name="cancelReservation") */
  489. public function cancelReservation(Request $request, $seat, Trip $trip)
  490. {
  491. $em = $this->entityManager;
  492. /** @var Seat $busSeat */
  493. $busSeat = $em->getRepository(Seat::class)->findOneBy([
  494. 'trip' => $trip,
  495. 'seatNumber' => $seat
  496. ]);
  497. $seatBooking = $em->getRepository(BookingSeat::class)->findOneBy([
  498. 'seat' => $busSeat
  499. ]);
  500. $reserve = $em->getRepository(Reservation::class)->findOneBy([
  501. 'seat' => $busSeat,
  502. 'canceledAt' => null
  503. ]);
  504. $busSeat->setStatus('AVAILABLE');
  505. $reserve->setCanceledAt(new DateTime());
  506. $reserve->setCanceledBy($this->getUser());
  507. $em->flush();
  508. return $this->redirectToRoute('viewTrip', ['trip' => $trip->getId()]);
  509. }
  510. /** @Route("/trip/{trip}/seat/{seat}/reserve", name="viewReserve") */
  511. public function reserve(Request $request, $seat, Trip $trip)
  512. {
  513. $em = $this->entityManager;
  514. /** @var UserStation $userStation */
  515. $userStation = $em->getRepository(UserStation::class)->findOneBy([
  516. 'user' => $this->getUser(),
  517. 'isActive' => true
  518. ]);
  519. /** @var Seat $busSeat */
  520. $busSeat = $em->getRepository(Seat::class)->findOneBy([
  521. 'trip' => $trip,
  522. 'seatNumber' => $seat
  523. ]);
  524. $seatBooking = $em->getRepository(BookingSeat::class)->findOneBy([
  525. 'seat' => $busSeat
  526. ]);
  527. $reservation = new Reservation();
  528. $reservation->setReservedAt(new DateTime());
  529. $reservation->setReservedBy($this->getUser());
  530. $reservation->setReservingStation($userStation->getStation());
  531. $reservation->setSeat($busSeat);
  532. $reserveForm = $this->createForm(ReserveType::class, $reservation);
  533. $reserveForm->handleRequest($request);
  534. if ($reserveForm->isSubmitted() && $reserveForm->isValid()) {
  535. $em->persist($reservation);
  536. $busSeat->setStatus('RESERVED');
  537. $em->flush();
  538. return $this->redirectToRoute('viewTrip', ['trip' => $trip->getId()]);
  539. }
  540. return $this->render('bus/reserve.html.twig', [
  541. 'seat' => $busSeat,
  542. 'trip' => $trip,
  543. 'booking' => $seatBooking,
  544. 'reserveForm' => $reserveForm->createView(),
  545. 'action' => $this->generateUrl('viewReserve', ['trip' => $trip->getId(), 'seat' => $busSeat->getSeatNumber()])
  546. ]);
  547. }
  548. /**
  549. * @Route("/ticket/{ticketId}", methods={"GET"}, name="generateTicket")
  550. */
  551. public function generateTicket(Request $request, $ticketId, MpdfFactory $MpdfFactory, TicketPdfBuilder $ticketPdfBuilder, LoggerInterface $logger): Response
  552. {
  553. $em = $this->entityManager;
  554. /** @var BookingSeat|null $seat */
  555. $seat = $em->getRepository(BookingSeat::class)->findOneBy([
  556. 'id' => $ticketId
  557. ]);
  558. if (!$seat) {
  559. return new Response('Ticket not found', Response::HTTP_NOT_FOUND);
  560. }
  561. try {
  562. $mPdf = $ticketPdfBuilder->build([$seat]);
  563. return $MpdfFactory->createInlineResponse($mPdf, "ticket_{$ticketId}.pdf");
  564. } catch (\Throwable $e) {
  565. $logger->error('Ticket PDF generation failed', ['ticketId' => $ticketId, 'exception' => $e]);
  566. return new Response('Failed to generate ticket', Response::HTTP_INTERNAL_SERVER_ERROR);
  567. }
  568. }
  569. /**
  570. * @Route("/daily_account", methods={"GET","POST"}, name="busDailyAccount")
  571. */
  572. public function dailyAccount(Request $request): Response
  573. {
  574. if ($request->isMethod('POST')) {
  575. $em = $this->entityManager;
  576. $today = new \DateTimeImmutable();
  577. $date = $today->format('Y-m-d');
  578. $user_id = $this->getUser()->getId();
  579. /** @var UserStation $userTown */
  580. $userTown = $this->entityManager->getRepository(UserStation::class)->findOneBy([
  581. 'user' => $this->getUser(),
  582. 'isActive' => true
  583. ], ['id' => 'DESC']);
  584. // Status filter added (ai_changes/BE-12): a bus_booking_seat row
  585. // used to always mean "paid" - now that pending/failed online
  586. // bookings can create one before payment succeeds, both queries
  587. // below need to exclude those explicitly. A NULL booking_id
  588. // still means "counter booking, always paid", so those stay in
  589. // unconditionally via the LEFT JOIN + IS NULL check.
  590. $sql = " SELECT d.route_name,a.paid_via,sum(a.amount) amount,c.trip_date FROM bus_booking_seat a
  591. JOIN bus_seat b ON b.id = a.seat_id
  592. JOIN bus_trip c ON c.id = b.trip_id
  593. JOIN bus_route d ON d.id = c.route_id
  594. LEFT JOIN bus_booking bk ON bk.id = a.booking_id
  595. WHERE date(a.created_at) = cast('2026-02-06' as date)
  596. AND (bk.id IS NULL OR bk.status = 'confirmed')
  597. GROUP BY paid_via, d.id,b.trip_id";
  598. $sqlTotal = "SELECT sum(a.amount) as total FROM bus_booking_seat a
  599. LEFT JOIN bus_booking bk ON bk.id = a.booking_id
  600. WHERE a.created_by = {$user_id} AND date(a.created_at) = cast('2026-02-06' as date)
  601. AND (bk.id IS NULL OR bk.status = 'confirmed')";
  602. $conn = $em->getConnection();
  603. try {
  604. $stmt = $conn->prepare($sql);
  605. $amounts = $stmt->executeQuery()->fetchAllAssociative();
  606. $stmtTotal = $conn->prepare($sqlTotal);
  607. $total = $stmtTotal->executeQuery()->fetchAssociative();
  608. return $this->render('bus/daily_account.html.twig', [
  609. 'date' => $date,
  610. 'amounts' => $amounts,
  611. 'total' => $total,
  612. 'user_town' => $userTown
  613. ]);
  614. } catch (Exception $e) {
  615. }
  616. }
  617. return $this->render('bus/daily_account.html.twig');
  618. }
  619. /**
  620. * @Route("/daily_account/list", methods={"POST", "GET"}, name="busDailyAccountList")
  621. */
  622. public function dailyAccountList(Request $request): JsonResponse
  623. {
  624. $em = $this->entityManager;
  625. $page = (int)($request->request->get('page') ?? $request->query->get('page') ?? 1);
  626. $rows = (int)($request->request->get('rows') ?? $request->query->get('rows') ?? 30);
  627. $offset = max(0, ($page - 1) * $rows);
  628. $searchDate = $request->request->get('date') ?? $request->query->get('date');
  629. if (!$searchDate) {
  630. $searchDate = (new \DateTime())->format('Y-m-d');
  631. }
  632. $params = ['searchDate' => $searchDate];
  633. $sqlTotal = "SELECT COUNT(a.id) as count_total, SUM(a.amount) as sum_amount
  634. FROM bus_booking_seat a
  635. LEFT JOIN bus_booking bk ON bk.id = a.booking_id
  636. WHERE date(a.created_at) = :searchDate
  637. AND (bk.id IS NULL OR bk.status = 'confirmed')";
  638. $sqlRows = "SELECT a.id, a.passenger, a.phone, a.amount, a.paid_via,
  639. DATE_FORMAT(a.created_at, '%Y-%m-%d %H:%i') as created_at,
  640. b.seat_number,
  641. DATE_FORMAT(c.trip_date, '%Y-%m-%d') as trip_date,
  642. d.route_name,
  643. COALESCE(st1.station_name, 'N/A') as origin_name,
  644. COALESCE(st2.station_name, 'N/A') as dest_name,
  645. COALESCE(u.username, 'System') as clerk_name
  646. FROM bus_booking_seat a
  647. JOIN bus_seat b ON b.id = a.seat_id
  648. JOIN bus_trip c ON c.id = b.trip_id
  649. JOIN bus_route d ON d.id = c.route_id
  650. LEFT JOIN bus_stop orig ON orig.id = a.origin
  651. LEFT JOIN station st1 ON st1.id = orig.station_id
  652. LEFT JOIN bus_stop dest ON dest.id = a.destination
  653. LEFT JOIN station st2 ON st2.id = dest.station_id
  654. LEFT JOIN user u ON u.id = a.created_by
  655. LEFT JOIN bus_booking bk ON bk.id = a.booking_id
  656. WHERE date(a.created_at) = :searchDate
  657. AND (bk.id IS NULL OR bk.status = 'confirmed')
  658. ORDER BY a.id DESC
  659. LIMIT {$rows} OFFSET {$offset}";
  660. $conn = $em->getConnection();
  661. $totalData = $conn->fetchAssociative($sqlTotal, $params);
  662. $totalCount = (int)($totalData['count_total'] ?? 0);
  663. $sumAmount = (float)($totalData['sum_amount'] ?? 0);
  664. $rowsData = $conn->fetchAllAssociative($sqlRows, $params);
  665. return new JsonResponse([
  666. 'total' => $totalCount,
  667. 'rows' => $rowsData,
  668. 'footer' => [
  669. [
  670. 'passenger' => 'TOTAL FARES',
  671. 'amount' => $sumAmount
  672. ]
  673. ]
  674. ]);
  675. }
  676. /**
  677. * @Route("/booking/seat_details/{id}", methods={"GET", "POST"}, name="busBookingSeatDetails")
  678. */
  679. public function getBookingSeatDetails($id): Response
  680. {
  681. $em = $this->entityManager;
  682. $conn = $em->getConnection();
  683. $sql = "SELECT a.id as ticket_id, a.passenger, a.phone, a.amount, a.paid_via,
  684. DATE_FORMAT(a.created_at, '%Y-%m-%d %H:%i:%s') as created_at,
  685. b.seat_number, b.status as seat_status,
  686. DATE_FORMAT(c.trip_date, '%Y-%m-%d') as trip_date,
  687. d.route_name,
  688. COALESCE(st1.station_name, 'N/A') as origin_name,
  689. COALESCE(st2.station_name, 'N/A') as dest_name,
  690. COALESCE(u.username, 'System') as clerk_name,
  691. bk.id as booking_group_id,
  692. bk.status as booking_status,
  693. m.transaction_id as mpesa_receipt
  694. FROM bus_booking_seat a
  695. JOIN bus_seat b ON b.id = a.seat_id
  696. JOIN bus_trip c ON c.id = b.trip_id
  697. JOIN bus_route d ON d.id = c.route_id
  698. LEFT JOIN bus_stop orig ON orig.id = a.origin
  699. LEFT JOIN station st1 ON st1.id = orig.station_id
  700. LEFT JOIN bus_stop dest ON dest.id = a.destination
  701. LEFT JOIN station st2 ON st2.id = dest.station_id
  702. LEFT JOIN user u ON u.id = a.created_by
  703. LEFT JOIN bus_booking bk ON bk.id = a.booking_id
  704. LEFT JOIN bus_booking_mpesa bbm ON bbm.bus_booking_id = bk.id
  705. LEFT JOIN mpesa m ON m.id = bbm.mpesa_id
  706. WHERE a.id = :id
  707. LIMIT 1";
  708. $details = $conn->fetchAssociative($sql, ['id' => $id]);
  709. if (!$details) {
  710. return new Response('<div class="alert alert-warning p-2">Details not found for ticket #' . htmlspecialchars((string)$id) . '</div>');
  711. }
  712. return $this->render('bus/seat_detail_panel.html.twig', [
  713. 'd' => $details
  714. ]);
  715. }
  716. private function addTrip(\App\Entity\Bus\Route $route, string $date)
  717. {
  718. $em = $this->entityManager;
  719. $dateTime = DateTime::createFromFormat('Y-m-d', $date);
  720. if (!$dateTime) {
  721. $dateTime = new DateTime($date);
  722. }
  723. /** @var RouteFareRule $fareRule */
  724. $fareRule = $em->getRepository(RouteFareRule::class)->findOneBy([
  725. 'route' => $route,
  726. ]);
  727. $trip = new Trip();
  728. $trip->setCreatedBy($this->getUser());
  729. $trip->setCreatedAt(new DateTime());
  730. $trip->setTripDate($dateTime);
  731. $trip->setRoute($route);
  732. $trip->setDestination($route->getDestination());
  733. $trip->setOrigin($route->getOrigin());
  734. $trip->setTripIndex(1);
  735. if ($fareRule) {
  736. $trip->setFare($fareRule->getNormalFare());
  737. }
  738. $departureStored = $route->getDepartureTime();
  739. $arrivalStored = $route->getEta();
  740. $baseDate = clone $dateTime;
  741. $departure = (clone $baseDate)->setTime(
  742. (int)$departureStored->format('H'),
  743. (int)$departureStored->format('i'),
  744. (int)$departureStored->format('s')
  745. );
  746. $arrival = (clone $baseDate)->setTime(
  747. (int)$arrivalStored->format('H'),
  748. (int)$arrivalStored->format('i'),
  749. (int)$arrivalStored->format('s')
  750. );
  751. if ($arrival <= $departure) {
  752. $arrival->modify('+1 day');
  753. }
  754. $trip->setDepartureTime($departure);
  755. $trip->setEta($arrival);
  756. /** @var Vehicle $vehicle */
  757. $vehicle = $em->getRepository(Vehicle::class)->findOneBy([
  758. 'regNumber' => 'KCE990Q'
  759. ]);
  760. $seats = [];
  761. for ($i = 1; $i <= $vehicle->getPassengerCapacity(); $i++) {
  762. $seat = new Seat();
  763. $seat->setStatus('AVAILABLE');
  764. $seat->setSeatNumber($i);
  765. $seat->setTrip($trip);
  766. $seat->setIsBooked(false);
  767. $seat->setCreatedAt(new DateTime());
  768. $seats[] = $seat;
  769. }
  770. $trip->setSeats($seats);
  771. $trip->setVehicle($vehicle);
  772. $tripVehicle = new TripVehicle();
  773. $tripVehicle->setCreatedAt(new DateTime());
  774. $tripVehicle->setTrip($trip);
  775. $tripVehicle->setCreatedBy($this->getUser());
  776. $tripVehicle->setIsCancelled(false);
  777. $tripVehicle->setVehicle($vehicle);
  778. $this->entityManager->persist($tripVehicle);
  779. $this->entityManager->persist($trip);
  780. $this->entityManager->flush();
  781. }
  782. private
  783. function jsonTestData()
  784. {
  785. $jsonData = '[{
  786. "id": 741421,
  787. "account_date": "2023-10-09",
  788. "expenses": 0,
  789. "created_at": "2023-10-09 19:59:54",
  790. "station_name": "MOMBASA",
  791. "sender_name": "patrick kinyua",
  792. "sender_phone_number": "0727 044 524",
  793. "receiver_name": "luke njoroge",
  794. "receiver_phone_number": "0705 179 108",
  795. "amount": 2100,
  796. "is_cancelled": 0
  797. },
  798. {
  799. "id": 741405,
  800. "account_date": "2023-10-09",
  801. "expenses": 0,
  802. "created_at": "2023-10-09 19:19:03",
  803. "station_name": "MERU",
  804. "sender_name": "phineas kirimi",
  805. "sender_phone_number": "0791 713 924",
  806. "receiver_name": "patrick munoru",
  807. "receiver_phone_number": "0720 949 054",
  808. "amount": 200,
  809. "is_cancelled": 0
  810. },
  811. {
  812. "id": 741395,
  813. "account_date": "2023-10-09",
  814. "expenses": 0,
  815. "created_at": "2023-10-09 19:09:05",
  816. "station_name": "kitui",
  817. "sender_name": "scout shop",
  818. "sender_phone_number": "0712 907 877",
  819. "receiver_name": "winfred ndolo",
  820. "receiver_phone_number": "0724 528 806",
  821. "amount": 200,
  822. "is_cancelled": 0
  823. },
  824. {
  825. "id": 741393,
  826. "account_date": "2023-10-09",
  827. "expenses": 0,
  828. "created_at": "2023-10-09 19:07:04",
  829. "station_name": "SIAKAGO",
  830. "sender_name": "james gichuki",
  831. "sender_phone_number": "0722 352 992",
  832. "receiver_name": "richard waitherero",
  833. "receiver_phone_number": "0728 454 129",
  834. "amount": 300,
  835. "is_cancelled": 0
  836. },
  837. {
  838. "id": 741378,
  839. "account_date": "2023-10-09",
  840. "expenses": 0,
  841. "created_at": "2023-10-09 18:55:25",
  842. "station_name": "NAIROBI",
  843. "sender_name": "denis munene",
  844. "sender_phone_number": "0740 270 344",
  845. "receiver_name": "tom ochieng",
  846. "receiver_phone_number": "0715 553 018",
  847. "amount": 200,
  848. "is_cancelled": 0
  849. },
  850. {
  851. "id": 741370,
  852. "account_date": "2023-10-09",
  853. "expenses": 0,
  854. "created_at": "2023-10-09 18:43:13",
  855. "station_name": "NAIROBI",
  856. "sender_name": "peter njagi",
  857. "sender_phone_number": "0722 883 774",
  858. "receiver_name": "jackson kanambiu",
  859. "receiver_phone_number": "0714 203 693",
  860. "amount": 200,
  861. "is_cancelled": 0
  862. },
  863. {
  864. "id": 741355,
  865. "account_date": "2023-10-09",
  866. "expenses": 0,
  867. "created_at": "2023-10-09 18:32:02",
  868. "station_name": "MOMBASA",
  869. "sender_name": "mary mbugi",
  870. "sender_phone_number": "0727 897 097",
  871. "receiver_name": "elizabeth nyawira",
  872. "receiver_phone_number": "0706 221 351",
  873. "amount": 300,
  874. "is_cancelled": 0
  875. },
  876. {
  877. "id": 741351,
  878. "account_date": "2023-10-09",
  879. "expenses": 0,
  880. "created_at": "2023-10-09 18:25:01",
  881. "station_name": "NAIROBI",
  882. "sender_name": "david thairu",
  883. "sender_phone_number": "0722 280 846",
  884. "receiver_name": "daniel kimondo",
  885. "receiver_phone_number": "0724 215 522",
  886. "amount": 200,
  887. "is_cancelled": 0
  888. },
  889. {
  890. "id": 741330,
  891. "account_date": "2023-10-09",
  892. "expenses": 0,
  893. "created_at": "2023-10-09 17:58:08",
  894. "station_name": "NAIROBI",
  895. "sender_name": "karen karimi",
  896. "sender_phone_number": "0714 509 299",
  897. "receiver_name": "victor muthoni",
  898. "receiver_phone_number": "0710 329 231",
  899. "amount": 200,
  900. "is_cancelled": 0
  901. },
  902. {
  903. "id": 741328,
  904. "account_date": "2023-10-09",
  905. "expenses": 0,
  906. "created_at": "2023-10-09 17:56:42",
  907. "station_name": "KATHWANA",
  908. "sender_name": "samwel",
  909. "sender_phone_number": "0722 595 844",
  910. "receiver_name": "dirangu ndungu",
  911. "receiver_phone_number": "0721 207 371",
  912. "amount": 250,
  913. "is_cancelled": 0
  914. },
  915. {
  916. "id": 741301,
  917. "account_date": "2023-10-09",
  918. "expenses": 0,
  919. "created_at": "2023-10-09 17:35:27",
  920. "station_name": "MOMBASA",
  921. "sender_name": "edith kimani",
  922. "sender_phone_number": "0714 567 205",
  923. "receiver_name": "okumu",
  924. "receiver_phone_number": "0714 567 205",
  925. "amount": 300,
  926. "is_cancelled": 0
  927. },
  928. {
  929. "id": 741289,
  930. "account_date": "2023-10-09",
  931. "expenses": 0,
  932. "created_at": "2023-10-09 17:26:09",
  933. "station_name": "MTWAPA",
  934. "sender_name": "caroline waweru",
  935. "sender_phone_number": "0712 353 926",
  936. "receiver_name": "GLADYS WAWERU",
  937. "receiver_phone_number": "0723 950 679",
  938. "amount": 300,
  939. "is_cancelled": 0
  940. },
  941. {
  942. "id": 741270,
  943. "account_date": "2023-10-09",
  944. "expenses": 0,
  945. "created_at": "2023-10-09 17:03:24",
  946. "station_name": "MWEA",
  947. "sender_name": "FOCUS EMBU",
  948. "sender_phone_number": "0716 447 206",
  949. "receiver_name": "FOCUS MWEA",
  950. "receiver_phone_number": "0721 606 746",
  951. "amount": 150,
  952. "is_cancelled": 0
  953. },
  954. {
  955. "id": 741266,
  956. "account_date": "2023-10-09",
  957. "expenses": 0,
  958. "created_at": "2023-10-09 16:58:43",
  959. "station_name": "THIKA",
  960. "sender_name": "KIMATHI KELVIN",
  961. "sender_phone_number": "0726 116 760",
  962. "receiver_name": "JAMES NGINGI",
  963. "receiver_phone_number": "0728 171 491",
  964. "amount": 200,
  965. "is_cancelled": 0
  966. },
  967. {
  968. "id": 741261,
  969. "account_date": "2023-10-09",
  970. "expenses": 0,
  971. "created_at": "2023-10-09 16:54:46",
  972. "station_name": "NAIROBI",
  973. "sender_name": "ROSALINE WAWIRA",
  974. "sender_phone_number": "0728 288 950",
  975. "receiver_name": "eras karimi",
  976. "receiver_phone_number": "0724 867 838",
  977. "amount": 200,
  978. "is_cancelled": 0
  979. },
  980. {
  981. "id": 741248,
  982. "account_date": "2023-10-09",
  983. "expenses": 0,
  984. "created_at": "2023-10-09 16:47:18",
  985. "station_name": "MOMBASA",
  986. "sender_name": "jemmimah",
  987. "sender_phone_number": "0711 485 954",
  988. "receiver_name": "rose khaidi",
  989. "receiver_phone_number": "0706 749 991",
  990. "amount": 300,
  991. "is_cancelled": 0
  992. },
  993. {
  994. "id": 741209,
  995. "account_date": "2023-10-09",
  996. "expenses": 0,
  997. "created_at": "2023-10-09 16:05:38",
  998. "station_name": "kitui",
  999. "sender_name": "eunice mambiro",
  1000. "sender_phone_number": "0720 997 508",
  1001. "receiver_name": "samwel muturi",
  1002. "receiver_phone_number": "0715 143 362",
  1003. "amount": 200,
  1004. "is_cancelled": 0
  1005. },
  1006. {
  1007. "id": 741185,
  1008. "account_date": "2023-10-09",
  1009. "expenses": 0,
  1010. "created_at": "2023-10-09 15:49:11",
  1011. "station_name": "NAIROBI",
  1012. "sender_name": "johnson njeru",
  1013. "sender_phone_number": "0724 586 321",
  1014. "receiver_name": "jane njoki njeru",
  1015. "receiver_phone_number": "0722 398 853",
  1016. "amount": 300,
  1017. "is_cancelled": 0
  1018. },
  1019. {
  1020. "id": 741175,
  1021. "account_date": "2023-10-09",
  1022. "expenses": 0,
  1023. "created_at": "2023-10-09 15:36:39",
  1024. "station_name": "NAIROBI",
  1025. "sender_name": "alexande david",
  1026. "sender_phone_number": "0795 762 309",
  1027. "receiver_name": "peter aunga",
  1028. "receiver_phone_number": "0715 044 187",
  1029. "amount": 200,
  1030. "is_cancelled": 0
  1031. },
  1032. {
  1033. "id": 741172,
  1034. "account_date": "2023-10-09",
  1035. "expenses": 0,
  1036. "created_at": "2023-10-09 15:33:17",
  1037. "station_name": "NAIROBI",
  1038. "sender_name": "j kimondo",
  1039. "sender_phone_number": "0722 830 014",
  1040. "receiver_name": "fred muiruri",
  1041. "receiver_phone_number": "0705 945 561",
  1042. "amount": 200,
  1043. "is_cancelled": 0
  1044. }
  1045. ]';
  1046. $arrayedFromJson = json_decode($jsonData, true);
  1047. return $arrayedFromJson;
  1048. }
  1049. /**
  1050. * @Route("/stk-push/initiate", methods={"POST"}, name="bus_stk_push_initiate")
  1051. */
  1052. public function initiateStkPush(Request $request): JsonResponse
  1053. {
  1054. $em = $this->entityManager;
  1055. $phone = $request->request->get('phone');
  1056. $amount = (float) $request->request->get('amount');
  1057. $seatNumber = $request->request->get('seat');
  1058. $tripId = $request->request->get('tripId');
  1059. if (!$phone || !$amount) {
  1060. return new JsonResponse(['success' => false, 'message' => 'Phone and amount are required.'], Response::HTTP_BAD_REQUEST);
  1061. }
  1062. $phone = preg_replace('/[^0-9]/', '', (string)$phone);
  1063. if (strpos($phone, '0') === 0) {
  1064. $phone = '254' . substr($phone, 1);
  1065. } elseif (strpos($phone, '7') === 0 || strpos($phone, '1') === 0) {
  1066. $phone = '254' . $phone;
  1067. }
  1068. /** @var MpesaAuth $mpesaAuth */
  1069. $mpesaAuth = $em->getRepository(MpesaAuth::class)->findOneBy([
  1070. 'paybill' => '4005975'
  1071. ]);
  1072. if (!$mpesaAuth) {
  1073. $mpesaAuth = $em->getRepository(MpesaAuth::class)->findOneBy([
  1074. 'shortCodeType' => 'BUS_STK'
  1075. ]);
  1076. }
  1077. if (!$mpesaAuth) {
  1078. return new JsonResponse(['success' => false, 'message' => 'M-Pesa STK configuration not found.'], Response::HTTP_INTERNAL_SERVER_ERROR);
  1079. }
  1080. $token = $this->getMpesaStkToken($mpesaAuth);
  1081. if (!$token) {
  1082. return new JsonResponse(['success' => false, 'message' => 'Failed to generate M-Pesa OAuth Access Token.'], Response::HTTP_INTERNAL_SERVER_ERROR);
  1083. }
  1084. $timestamp = (new DateTime())->format('YmdHis');
  1085. $businessShortCode = $mpesaAuth->getPaybill();
  1086. $passKey = $mpesaAuth->getPassKey();
  1087. $password = base64_encode($businessShortCode . $passKey . $timestamp);
  1088. $callbackUrl = $this->generateUrl('bus_stk_push_callback', [], UrlGeneratorInterface::ABSOLUTE_URL);
  1089. $postData = [
  1090. 'BusinessShortCode' => $businessShortCode,
  1091. 'Password' => $password,
  1092. 'Timestamp' => $timestamp,
  1093. 'TransactionType' => 'CustomerPayBillOnline',
  1094. 'Amount' => (int) $amount,
  1095. 'PartyA' => $phone,
  1096. 'PartyB' => $businessShortCode,
  1097. 'PhoneNumber' => $phone,
  1098. 'CallBackURL' => $callbackUrl,
  1099. 'AccountReference' => 'SEAT' . ($seatNumber ? '_' . $seatNumber : ''),
  1100. 'TransactionDesc' => 'Bus Booking Seat ' . $seatNumber
  1101. ];
  1102. $curl = curl_init();
  1103. curl_setopt_array($curl, [
  1104. CURLOPT_URL => "https://api.safaricom.co.ke/mpesa/stkpush/v1/processrequest",
  1105. CURLOPT_HTTPHEADER => [
  1106. "Content-Type: application/json",
  1107. "Authorization: Bearer {$token}"
  1108. ],
  1109. CURLOPT_RETURNTRANSFER => true,
  1110. CURLOPT_POST => true,
  1111. CURLOPT_POSTFIELDS => json_encode($postData),
  1112. CURLOPT_SSL_VERIFYPEER => false,
  1113. CURLOPT_TIMEOUT => 30,
  1114. ]);
  1115. $responseRaw = curl_exec($curl);
  1116. curl_close($curl);
  1117. $res = json_decode($responseRaw, true);
  1118. if (isset($res['ResponseCode']) && $res['ResponseCode'] == '0') {
  1119. $checkoutRequestId = $res['CheckoutRequestID'] ?? '';
  1120. $merchantRequestId = $res['MerchantRequestID'] ?? '';
  1121. $mpesaReq = new MpesaPaymentRequest();
  1122. $mpesaReq->setMerchantRequestId($merchantRequestId);
  1123. $mpesaReq->setCheckoutRequestId($checkoutRequestId);
  1124. $mpesaReq->setDescription("Bus Booking Seat {$seatNumber} Trip {$tripId}");
  1125. $mpesaReq->setMessage($res['CustomerMessage'] ?? $res['ResponseDescription'] ?? '');
  1126. $mpesaReq->setPhone((int) $phone);
  1127. $mpesaReq->setCreatedAt(new DateTime());
  1128. $em->persist($mpesaReq);
  1129. $em->flush();
  1130. return new JsonResponse([
  1131. 'success' => true,
  1132. 'checkoutRequestId' => $checkoutRequestId,
  1133. 'message' => 'STK Push sent to ' . $phone . '. Please enter PIN on phone.'
  1134. ]);
  1135. } else {
  1136. $errorMsg = $res['errorMessage'] ?? $res['ResponseDescription'] ?? 'Failed to send STK Push prompt.';
  1137. return new JsonResponse([
  1138. 'success' => false,
  1139. 'message' => $errorMsg
  1140. ], Response::HTTP_BAD_REQUEST);
  1141. }
  1142. }
  1143. /**
  1144. * @Route("/stk-push/callback", methods={"POST"}, name="bus_stk_push_callback")
  1145. */
  1146. public function callbackStkPush(Request $request): JsonResponse
  1147. {
  1148. $em = $this->entityManager;
  1149. $content = $request->getContent();
  1150. $data = json_decode($content, true);
  1151. $stkCallback = $data['Body']['stkCallback'] ?? null;
  1152. if ($stkCallback) {
  1153. $merchantRequestId = $stkCallback['MerchantRequestID'] ?? '';
  1154. $checkoutRequestId = $stkCallback['CheckoutRequestID'] ?? '';
  1155. $resultCode = (int) ($stkCallback['ResultCode'] ?? -1);
  1156. $resultDesc = $stkCallback['ResultDesc'] ?? '';
  1157. $mpesaResp = new MpesaResponse();
  1158. $mpesaResp->setMerchantRequestId($merchantRequestId);
  1159. $mpesaResp->setCheckoutRequestId($checkoutRequestId);
  1160. $mpesaResp->setCode($resultCode);
  1161. $mpesaResp->setDescription($resultDesc);
  1162. $mpesaResp->setCreatedAt(new DateTime());
  1163. $em->persist($mpesaResp);
  1164. if ($resultCode === 0) {
  1165. $items = $stkCallback['CallbackMetadata']['Item'] ?? [];
  1166. $receipt = '';
  1167. $amount = 0;
  1168. $phone = '';
  1169. foreach ($items as $item) {
  1170. if (($item['Name'] ?? '') === 'MpesaReceiptNumber') {
  1171. $receipt = (string) $item['Value'];
  1172. }
  1173. if (($item['Name'] ?? '') === 'Amount') {
  1174. $amount = (float) $item['Value'];
  1175. }
  1176. if (($item['Name'] ?? '') === 'PhoneNumber') {
  1177. $phone = (string) $item['Value'];
  1178. }
  1179. }
  1180. if ($receipt) {
  1181. $existing = $em->getRepository(Mpesa::class)->findOneBy(['transactionId' => $receipt]);
  1182. if (!$existing) {
  1183. $mpesa = new Mpesa();
  1184. $mpesa->setTransactionId($receipt);
  1185. $mpesa->setTransactionType('CustomerPayBillOnline');
  1186. $mpesa->setTransactionTime(date('Y-m-d H:i:s'));
  1187. $mpesa->setTransactionAmount($amount);
  1188. $mpesa->setRefNumber($checkoutRequestId);
  1189. $mpesa->setShortCode(4005975);
  1190. $mpesa->setBalance(0);
  1191. $mpesa->setMsisdn($phone);
  1192. $mpesa->setFirstName('M-Pesa');
  1193. $mpesa->setLastName('Customer');
  1194. $mpesa->setMiddleName('');
  1195. $mpesa->setCreatedAt(new DateTime());
  1196. $mpesa->setIsUsed(false);
  1197. $em->persist($mpesa);
  1198. }
  1199. }
  1200. }
  1201. $em->flush();
  1202. }
  1203. return new JsonResponse(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
  1204. }
  1205. /**
  1206. * @Route("/stk-push/status/{checkoutRequestId}", methods={"GET"}, name="bus_stk_push_status")
  1207. */
  1208. public function checkStkPushStatus(string $checkoutRequestId): JsonResponse
  1209. {
  1210. $em = $this->entityManager;
  1211. $response = $em->getRepository(MpesaResponse::class)->findOneBy([
  1212. 'checkoutRequestId' => $checkoutRequestId
  1213. ]);
  1214. if ($response) {
  1215. if ($response->getCode() === 0) {
  1216. $mpesa = $em->getRepository(Mpesa::class)->findOneBy([
  1217. 'refNumber' => $checkoutRequestId
  1218. ]);
  1219. if (!$mpesa) {
  1220. $mpesa = $em->getRepository(Mpesa::class)->findOneBy([
  1221. 'shortCode' => 4005975,
  1222. 'isUsed' => false
  1223. ], ['id' => 'DESC']);
  1224. }
  1225. return new JsonResponse([
  1226. 'status' => 'SUCCESS',
  1227. 'mpesaId' => $mpesa ? $mpesa->getId() : null,
  1228. 'receipt' => $mpesa ? $mpesa->getTransactionId() : '',
  1229. 'amount' => $mpesa ? $mpesa->getTransactionAmount() : 0,
  1230. 'message' => 'Payment received successfully!'
  1231. ]);
  1232. } else {
  1233. return new JsonResponse([
  1234. 'status' => 'FAILED',
  1235. 'message' => $response->getDescription() ?: 'Payment failed or cancelled by customer.'
  1236. ]);
  1237. }
  1238. }
  1239. return new JsonResponse(['status' => 'PENDING']);
  1240. }
  1241. private function getMpesaStkToken(MpesaAuth $mpesaAuth): ?string
  1242. {
  1243. if ($mpesaAuth->getToken() && $mpesaAuth->getTokenUpdatedAt()) {
  1244. $diff = (new DateTime())->getTimestamp() - $mpesaAuth->getTokenUpdatedAt()->getTimestamp();
  1245. if ($diff < 3000) {
  1246. return $mpesaAuth->getToken();
  1247. }
  1248. }
  1249. $url = "https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials";
  1250. $credentials = base64_encode("{$mpesaAuth->getConsumerKey()}:{$mpesaAuth->getConsumerSecret()}");
  1251. $curl = curl_init();
  1252. curl_setopt_array($curl, [
  1253. CURLOPT_URL => $url,
  1254. CURLOPT_HTTPHEADER => [
  1255. "Content-Type: application/json",
  1256. "Authorization: Basic {$credentials}"
  1257. ],
  1258. CURLOPT_RETURNTRANSFER => true,
  1259. CURLOPT_HTTPGET => true,
  1260. CURLOPT_SSL_VERIFYPEER => false,
  1261. CURLOPT_TIMEOUT => 15,
  1262. ]);
  1263. $response = curl_exec($curl);
  1264. curl_close($curl);
  1265. if ($response) {
  1266. $result = json_decode($response, true);
  1267. if (isset($result['access_token'])) {
  1268. $token = $result['access_token'];
  1269. $mpesaAuth->setToken($token);
  1270. $mpesaAuth->setTokenUpdatedAt(new DateTime());
  1271. $this->entityManager->flush();
  1272. return $token;
  1273. }
  1274. }
  1275. return $mpesaAuth->getToken();
  1276. }
  1277. /**
  1278. * @Route("/trip/{trip}/fare", methods={"GET"}, name="getTripFare")
  1279. */
  1280. public function getTripFare(Request $request, Trip $trip): JsonResponse
  1281. {
  1282. $em = $this->entityManager;
  1283. $originId = $request->query->get('origin');
  1284. $destinationId = $request->query->get('destination');
  1285. $originStop = $originId ? $em->getRepository(Stop::class)->find($originId) : null;
  1286. $destStop = $destinationId ? $em->getRepository(Stop::class)->find($destinationId) : null;
  1287. $fare = $this->getDatabaseFare($trip, $originStop, $destStop);
  1288. return new JsonResponse(['fare' => $fare]);
  1289. }
  1290. private function getDatabaseFare(Trip $trip, ?Stop $originStop = null, ?Stop $destinationStop = null): float
  1291. {
  1292. $em = $this->entityManager;
  1293. $originStation = $originStop ? $originStop->getStation() : null;
  1294. $destStation = $destinationStop ? $destinationStop->getStation() : null;
  1295. if ($originStation && $destStation) {
  1296. $fareRule = $em->getRepository(RouteFareRule::class)->findOneBy([
  1297. 'route' => $trip->getRoute(),
  1298. 'origin' => $originStation,
  1299. 'destination' => $destStation
  1300. ]);
  1301. if ($fareRule && $fareRule->getNormalFare() > 0) {
  1302. return (float) $fareRule->getNormalFare();
  1303. }
  1304. }
  1305. $fareRule = $em->getRepository(RouteFareRule::class)->findOneBy([
  1306. 'route' => $trip->getRoute()
  1307. ]);
  1308. if ($fareRule && $fareRule->getNormalFare() > 0) {
  1309. return (float) $fareRule->getNormalFare();
  1310. }
  1311. return (float) ($trip->getFare() ?: 0);
  1312. }
  1313. }