src/Bus/BookingController.php line 376

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. // The amount field is now clerk-editable (see BookingType::amount), so only
  406. // fall back to the route's database fare when nothing usable was submitted -
  407. // don't clobber a fare the clerk deliberately typed in.
  408. if (!($booking->getAmount() > 0)) {
  409. $dbFare = $this->getDatabaseFare($trip, $booking->getOrigin(), $booking->getDestination());
  410. if ($dbFare > 0) {
  411. $booking->setAmount($dbFare);
  412. }
  413. }
  414. dump($request->getMethod());
  415. if ($bookingForm->has('mpesa')) {
  416. if ($bookingForm->get('mpesa') && $bookingForm->get('mpesa')->getData()) {
  417. /** @var Mpesa $mpesa */
  418. $mpesa = $bookingForm->get('mpesa')->getData();
  419. if ($booking->getPaidVia() == 'MPESA' || $booking->getPaidVia() == 'MPESA_EXPRESS') {
  420. dump($mpesa);
  421. if ($booking->getAmount() > $mpesa->getTransactionAmount()) {
  422. $bookingForm->get('amount')->addError(new FormError('Mpesa amount not enough'));
  423. }
  424. } else if ($booking->getPaidVia() == 'CASH_MPESA') {
  425. if ($mpesa->getTransactionAmount() >= $booking->getAmount()) {
  426. $bookingForm->get('amount')->addError(new FormError('Please check the amounts'));
  427. }
  428. }
  429. }
  430. }
  431. if ($bookingForm->isValid()) {
  432. if ($bookingForm->has('mpesa') && $bookingForm->get('mpesa') && $bookingForm->get('mpesa')->getData()) {
  433. // dump($bookingForm->get('mpesa')->getData());
  434. /** @var Mpesa $mpesa */
  435. $mpesa = $bookingForm->get('mpesa')->getData();
  436. $bookingMpesa = new BookingMpesa();
  437. $bookingMpesa->setBooking($booking);
  438. $bookingMpesa->setCreatedAt(new DateTime());
  439. $bookingMpesa->setCreatedBy($this->getUser());
  440. $bookingMpesa->setAmount($booking->getAmount());
  441. $bookingMpesa->setBalance(0);
  442. $bookingMpesa->setMpesa($mpesa);
  443. $mpesa->setIsUsed(true);
  444. $this->entityManager->persist($bookingMpesa);
  445. }
  446. $departureTime = $booking->getOrigin()->getDepartureTime();
  447. $eta = $booking->getOrigin()->getEta();
  448. $booking->setDepartureTime($departureTime);
  449. $booking->setEta($eta);
  450. $booking->getSeat()->setStatus('booked');
  451. $this->entityManager->persist($booking);
  452. $this->entityManager->flush();
  453. $this->ticketSmsSender->sendForSeat($booking);
  454. // return $this->redirectToRoute("@viewTrip?currentSeat={$seat}", ['trip' => $trip->getId()]);
  455. return $this->redirect("/bus/trip/{$trip->getId()}?currentSeat={$seat}");
  456. }
  457. }
  458. return $this->render('bus/seat_detail.html.twig', [
  459. 'seat' => $busSeat,
  460. 'trip' => $trip,
  461. 'booking' => $seatBooking,
  462. 'bookingForm' => $bookingForm->createView(),
  463. 'bookingStation' => $bookingUserStation,
  464. 'reservation' => $reserve,
  465. 'user_station' => $userStation
  466. ]);
  467. }
  468. /** @Route("/trip/{trip}/seat/{seat}/book_reserve_opt", name="viewReserveBookOpt") */
  469. public function bookingReserveOption(Request $request, $seat, Trip $trip)
  470. {
  471. $em = $this->entityManager;
  472. /** @var Seat $busSeat */
  473. $busSeat = $em->getRepository(Seat::class)->findOneBy([
  474. 'trip' => $trip,
  475. 'seatNumber' => $seat
  476. ]);
  477. $reserve = $em->getRepository(Reservation::class)->findOneBy([
  478. 'seat' => $busSeat,
  479. 'canceledAt' => null
  480. ]);
  481. $seatBooking = $em->getRepository(BookingSeat::class)->findOneBy([
  482. 'seat' => $busSeat,
  483. ]);
  484. if ($seatBooking || $reserve) {
  485. return $this->redirectToRoute('viewTripSeat', ['trip' => $trip->getId(), 'seat' => $busSeat->getSeatNumber()]);
  486. }
  487. return $this->render('bus/book_reserve.html.twig', [
  488. 'seat' => $busSeat,
  489. 'trip' => $trip,
  490. 'booking' => $seatBooking
  491. ]);
  492. }
  493. /** @Route("/trip/{trip}/seat/{seat}/cancel_reservation", name="cancelReservation") */
  494. public function cancelReservation(Request $request, $seat, Trip $trip)
  495. {
  496. $em = $this->entityManager;
  497. /** @var Seat $busSeat */
  498. $busSeat = $em->getRepository(Seat::class)->findOneBy([
  499. 'trip' => $trip,
  500. 'seatNumber' => $seat
  501. ]);
  502. $seatBooking = $em->getRepository(BookingSeat::class)->findOneBy([
  503. 'seat' => $busSeat
  504. ]);
  505. $reserve = $em->getRepository(Reservation::class)->findOneBy([
  506. 'seat' => $busSeat,
  507. 'canceledAt' => null
  508. ]);
  509. $busSeat->setStatus('AVAILABLE');
  510. $reserve->setCanceledAt(new DateTime());
  511. $reserve->setCanceledBy($this->getUser());
  512. $em->flush();
  513. return $this->redirectToRoute('viewTrip', ['trip' => $trip->getId()]);
  514. }
  515. /** @Route("/trip/{trip}/seat/{seat}/reserve", name="viewReserve") */
  516. public function reserve(Request $request, $seat, Trip $trip)
  517. {
  518. $em = $this->entityManager;
  519. /** @var UserStation $userStation */
  520. $userStation = $em->getRepository(UserStation::class)->findOneBy([
  521. 'user' => $this->getUser(),
  522. 'isActive' => true
  523. ]);
  524. /** @var Seat $busSeat */
  525. $busSeat = $em->getRepository(Seat::class)->findOneBy([
  526. 'trip' => $trip,
  527. 'seatNumber' => $seat
  528. ]);
  529. $seatBooking = $em->getRepository(BookingSeat::class)->findOneBy([
  530. 'seat' => $busSeat
  531. ]);
  532. $reservation = new Reservation();
  533. $reservation->setReservedAt(new DateTime());
  534. $reservation->setReservedBy($this->getUser());
  535. $reservation->setReservingStation($userStation->getStation());
  536. $reservation->setSeat($busSeat);
  537. $reserveForm = $this->createForm(ReserveType::class, $reservation);
  538. $reserveForm->handleRequest($request);
  539. if ($reserveForm->isSubmitted() && $reserveForm->isValid()) {
  540. $em->persist($reservation);
  541. $busSeat->setStatus('RESERVED');
  542. $em->flush();
  543. return $this->redirectToRoute('viewTrip', ['trip' => $trip->getId()]);
  544. }
  545. return $this->render('bus/reserve.html.twig', [
  546. 'seat' => $busSeat,
  547. 'trip' => $trip,
  548. 'booking' => $seatBooking,
  549. 'reserveForm' => $reserveForm->createView(),
  550. 'action' => $this->generateUrl('viewReserve', ['trip' => $trip->getId(), 'seat' => $busSeat->getSeatNumber()])
  551. ]);
  552. }
  553. /**
  554. * @Route("/ticket/{ticketId}", methods={"GET"}, name="generateTicket")
  555. */
  556. public function generateTicket(Request $request, $ticketId, MpdfFactory $MpdfFactory, TicketPdfBuilder $ticketPdfBuilder, LoggerInterface $logger): Response
  557. {
  558. $em = $this->entityManager;
  559. /** @var BookingSeat|null $seat */
  560. $seat = $em->getRepository(BookingSeat::class)->findOneBy([
  561. 'id' => $ticketId
  562. ]);
  563. if (!$seat) {
  564. return new Response('Ticket not found', Response::HTTP_NOT_FOUND);
  565. }
  566. try {
  567. $mPdf = $ticketPdfBuilder->build([$seat]);
  568. return $MpdfFactory->createInlineResponse($mPdf, "ticket_{$ticketId}.pdf");
  569. } catch (\Throwable $e) {
  570. $logger->error('Ticket PDF generation failed', ['ticketId' => $ticketId, 'exception' => $e]);
  571. return new Response('Failed to generate ticket', Response::HTTP_INTERNAL_SERVER_ERROR);
  572. }
  573. }
  574. /**
  575. * @Route("/daily_account", methods={"GET","POST"}, name="busDailyAccount")
  576. */
  577. public function dailyAccount(Request $request): Response
  578. {
  579. if ($request->isMethod('POST')) {
  580. $em = $this->entityManager;
  581. $today = new \DateTimeImmutable();
  582. $date = $today->format('Y-m-d');
  583. $user_id = $this->getUser()->getId();
  584. /** @var UserStation $userTown */
  585. $userTown = $this->entityManager->getRepository(UserStation::class)->findOneBy([
  586. 'user' => $this->getUser(),
  587. 'isActive' => true
  588. ], ['id' => 'DESC']);
  589. // Status filter added (ai_changes/BE-12): a bus_booking_seat row
  590. // used to always mean "paid" - now that pending/failed online
  591. // bookings can create one before payment succeeds, both queries
  592. // below need to exclude those explicitly. A NULL booking_id
  593. // still means "counter booking, always paid", so those stay in
  594. // unconditionally via the LEFT JOIN + IS NULL check.
  595. $sql = " SELECT d.route_name,a.paid_via,sum(a.amount) amount,c.trip_date FROM bus_booking_seat a
  596. JOIN bus_seat b ON b.id = a.seat_id
  597. JOIN bus_trip c ON c.id = b.trip_id
  598. JOIN bus_route d ON d.id = c.route_id
  599. LEFT JOIN bus_booking bk ON bk.id = a.booking_id
  600. WHERE date(a.created_at) = cast('2026-02-06' as date)
  601. AND (bk.id IS NULL OR bk.status = 'confirmed')
  602. GROUP BY paid_via, d.id,b.trip_id";
  603. $sqlTotal = "SELECT sum(a.amount) as total FROM bus_booking_seat a
  604. LEFT JOIN bus_booking bk ON bk.id = a.booking_id
  605. WHERE a.created_by = {$user_id} AND date(a.created_at) = cast('2026-02-06' as date)
  606. AND (bk.id IS NULL OR bk.status = 'confirmed')";
  607. $conn = $em->getConnection();
  608. try {
  609. $stmt = $conn->prepare($sql);
  610. $amounts = $stmt->executeQuery()->fetchAllAssociative();
  611. $stmtTotal = $conn->prepare($sqlTotal);
  612. $total = $stmtTotal->executeQuery()->fetchAssociative();
  613. return $this->render('bus/daily_account.html.twig', [
  614. 'date' => $date,
  615. 'amounts' => $amounts,
  616. 'total' => $total,
  617. 'user_town' => $userTown
  618. ]);
  619. } catch (Exception $e) {
  620. }
  621. }
  622. return $this->render('bus/daily_account.html.twig');
  623. }
  624. /**
  625. * @Route("/daily_account/list", methods={"POST", "GET"}, name="busDailyAccountList")
  626. */
  627. public function dailyAccountList(Request $request): JsonResponse
  628. {
  629. $em = $this->entityManager;
  630. $page = (int)($request->request->get('page') ?? $request->query->get('page') ?? 1);
  631. $rows = (int)($request->request->get('rows') ?? $request->query->get('rows') ?? 30);
  632. $offset = max(0, ($page - 1) * $rows);
  633. $searchDate = $request->request->get('date') ?? $request->query->get('date');
  634. if (!$searchDate) {
  635. $searchDate = (new \DateTime())->format('Y-m-d');
  636. }
  637. $params = ['searchDate' => $searchDate];
  638. $sqlTotal = "SELECT COUNT(a.id) as count_total, SUM(a.amount) as sum_amount
  639. FROM bus_booking_seat a
  640. LEFT JOIN bus_booking bk ON bk.id = a.booking_id
  641. WHERE date(a.created_at) = :searchDate
  642. AND (bk.id IS NULL OR bk.status = 'confirmed')";
  643. $sqlRows = "SELECT a.id, a.passenger, a.phone, a.amount, a.paid_via,
  644. DATE_FORMAT(a.created_at, '%Y-%m-%d %H:%i') as created_at,
  645. b.seat_number,
  646. DATE_FORMAT(c.trip_date, '%Y-%m-%d') as trip_date,
  647. d.route_name,
  648. COALESCE(st1.station_name, 'N/A') as origin_name,
  649. COALESCE(st2.station_name, 'N/A') as dest_name,
  650. COALESCE(u.username, 'System') as clerk_name
  651. FROM bus_booking_seat a
  652. JOIN bus_seat b ON b.id = a.seat_id
  653. JOIN bus_trip c ON c.id = b.trip_id
  654. JOIN bus_route d ON d.id = c.route_id
  655. LEFT JOIN bus_stop orig ON orig.id = a.origin
  656. LEFT JOIN station st1 ON st1.id = orig.station_id
  657. LEFT JOIN bus_stop dest ON dest.id = a.destination
  658. LEFT JOIN station st2 ON st2.id = dest.station_id
  659. LEFT JOIN user u ON u.id = a.created_by
  660. LEFT JOIN bus_booking bk ON bk.id = a.booking_id
  661. WHERE date(a.created_at) = :searchDate
  662. AND (bk.id IS NULL OR bk.status = 'confirmed')
  663. ORDER BY a.id DESC
  664. LIMIT {$rows} OFFSET {$offset}";
  665. $conn = $em->getConnection();
  666. $totalData = $conn->fetchAssociative($sqlTotal, $params);
  667. $totalCount = (int)($totalData['count_total'] ?? 0);
  668. $sumAmount = (float)($totalData['sum_amount'] ?? 0);
  669. $rowsData = $conn->fetchAllAssociative($sqlRows, $params);
  670. return new JsonResponse([
  671. 'total' => $totalCount,
  672. 'rows' => $rowsData,
  673. 'footer' => [
  674. [
  675. 'passenger' => 'TOTAL FARES',
  676. 'amount' => $sumAmount
  677. ]
  678. ]
  679. ]);
  680. }
  681. /**
  682. * @Route("/booking/seat_details/{id}", methods={"GET", "POST"}, name="busBookingSeatDetails")
  683. */
  684. public function getBookingSeatDetails($id): Response
  685. {
  686. $em = $this->entityManager;
  687. $conn = $em->getConnection();
  688. $sql = "SELECT a.id as ticket_id, a.passenger, a.phone, a.amount, a.paid_via,
  689. DATE_FORMAT(a.created_at, '%Y-%m-%d %H:%i:%s') as created_at,
  690. b.seat_number, b.status as seat_status,
  691. DATE_FORMAT(c.trip_date, '%Y-%m-%d') as trip_date,
  692. d.route_name,
  693. COALESCE(st1.station_name, 'N/A') as origin_name,
  694. COALESCE(st2.station_name, 'N/A') as dest_name,
  695. COALESCE(u.username, 'System') as clerk_name,
  696. bk.id as booking_group_id,
  697. bk.status as booking_status,
  698. m.transaction_id as mpesa_receipt
  699. FROM bus_booking_seat a
  700. JOIN bus_seat b ON b.id = a.seat_id
  701. JOIN bus_trip c ON c.id = b.trip_id
  702. JOIN bus_route d ON d.id = c.route_id
  703. LEFT JOIN bus_stop orig ON orig.id = a.origin
  704. LEFT JOIN station st1 ON st1.id = orig.station_id
  705. LEFT JOIN bus_stop dest ON dest.id = a.destination
  706. LEFT JOIN station st2 ON st2.id = dest.station_id
  707. LEFT JOIN user u ON u.id = a.created_by
  708. LEFT JOIN bus_booking bk ON bk.id = a.booking_id
  709. LEFT JOIN bus_booking_mpesa bbm ON bbm.bus_booking_id = bk.id
  710. LEFT JOIN mpesa m ON m.id = bbm.mpesa_id
  711. WHERE a.id = :id
  712. LIMIT 1";
  713. $details = $conn->fetchAssociative($sql, ['id' => $id]);
  714. if (!$details) {
  715. return new Response('<div class="alert alert-warning p-2">Details not found for ticket #' . htmlspecialchars((string)$id) . '</div>');
  716. }
  717. return $this->render('bus/seat_detail_panel.html.twig', [
  718. 'd' => $details
  719. ]);
  720. }
  721. private function addTrip(\App\Entity\Bus\Route $route, string $date)
  722. {
  723. $em = $this->entityManager;
  724. $dateTime = DateTime::createFromFormat('Y-m-d', $date);
  725. if (!$dateTime) {
  726. $dateTime = new DateTime($date);
  727. }
  728. /** @var RouteFareRule $fareRule */
  729. $fareRule = $em->getRepository(RouteFareRule::class)->findOneBy([
  730. 'route' => $route,
  731. ]);
  732. $trip = new Trip();
  733. $trip->setCreatedBy($this->getUser());
  734. $trip->setCreatedAt(new DateTime());
  735. $trip->setTripDate($dateTime);
  736. $trip->setRoute($route);
  737. $trip->setDestination($route->getDestination());
  738. $trip->setOrigin($route->getOrigin());
  739. $trip->setTripIndex(1);
  740. if ($fareRule) {
  741. $trip->setFare($fareRule->getNormalFare());
  742. }
  743. $departureStored = $route->getDepartureTime();
  744. $arrivalStored = $route->getEta();
  745. $baseDate = clone $dateTime;
  746. $departure = (clone $baseDate)->setTime(
  747. (int)$departureStored->format('H'),
  748. (int)$departureStored->format('i'),
  749. (int)$departureStored->format('s')
  750. );
  751. $arrival = (clone $baseDate)->setTime(
  752. (int)$arrivalStored->format('H'),
  753. (int)$arrivalStored->format('i'),
  754. (int)$arrivalStored->format('s')
  755. );
  756. if ($arrival <= $departure) {
  757. $arrival->modify('+1 day');
  758. }
  759. $trip->setDepartureTime($departure);
  760. $trip->setEta($arrival);
  761. /** @var Vehicle $vehicle */
  762. $vehicle = $em->getRepository(Vehicle::class)->findOneBy([
  763. 'regNumber' => 'KCE990Q'
  764. ]);
  765. $seats = [];
  766. for ($i = 1; $i <= $vehicle->getPassengerCapacity(); $i++) {
  767. $seat = new Seat();
  768. $seat->setStatus('AVAILABLE');
  769. $seat->setSeatNumber($i);
  770. $seat->setTrip($trip);
  771. $seat->setIsBooked(false);
  772. $seat->setCreatedAt(new DateTime());
  773. $seats[] = $seat;
  774. }
  775. $trip->setSeats($seats);
  776. $trip->setVehicle($vehicle);
  777. $tripVehicle = new TripVehicle();
  778. $tripVehicle->setCreatedAt(new DateTime());
  779. $tripVehicle->setTrip($trip);
  780. $tripVehicle->setCreatedBy($this->getUser());
  781. $tripVehicle->setIsCancelled(false);
  782. $tripVehicle->setVehicle($vehicle);
  783. $this->entityManager->persist($tripVehicle);
  784. $this->entityManager->persist($trip);
  785. $this->entityManager->flush();
  786. }
  787. private
  788. function jsonTestData()
  789. {
  790. $jsonData = '[{
  791. "id": 741421,
  792. "account_date": "2023-10-09",
  793. "expenses": 0,
  794. "created_at": "2023-10-09 19:59:54",
  795. "station_name": "MOMBASA",
  796. "sender_name": "patrick kinyua",
  797. "sender_phone_number": "0727 044 524",
  798. "receiver_name": "luke njoroge",
  799. "receiver_phone_number": "0705 179 108",
  800. "amount": 2100,
  801. "is_cancelled": 0
  802. },
  803. {
  804. "id": 741405,
  805. "account_date": "2023-10-09",
  806. "expenses": 0,
  807. "created_at": "2023-10-09 19:19:03",
  808. "station_name": "MERU",
  809. "sender_name": "phineas kirimi",
  810. "sender_phone_number": "0791 713 924",
  811. "receiver_name": "patrick munoru",
  812. "receiver_phone_number": "0720 949 054",
  813. "amount": 200,
  814. "is_cancelled": 0
  815. },
  816. {
  817. "id": 741395,
  818. "account_date": "2023-10-09",
  819. "expenses": 0,
  820. "created_at": "2023-10-09 19:09:05",
  821. "station_name": "kitui",
  822. "sender_name": "scout shop",
  823. "sender_phone_number": "0712 907 877",
  824. "receiver_name": "winfred ndolo",
  825. "receiver_phone_number": "0724 528 806",
  826. "amount": 200,
  827. "is_cancelled": 0
  828. },
  829. {
  830. "id": 741393,
  831. "account_date": "2023-10-09",
  832. "expenses": 0,
  833. "created_at": "2023-10-09 19:07:04",
  834. "station_name": "SIAKAGO",
  835. "sender_name": "james gichuki",
  836. "sender_phone_number": "0722 352 992",
  837. "receiver_name": "richard waitherero",
  838. "receiver_phone_number": "0728 454 129",
  839. "amount": 300,
  840. "is_cancelled": 0
  841. },
  842. {
  843. "id": 741378,
  844. "account_date": "2023-10-09",
  845. "expenses": 0,
  846. "created_at": "2023-10-09 18:55:25",
  847. "station_name": "NAIROBI",
  848. "sender_name": "denis munene",
  849. "sender_phone_number": "0740 270 344",
  850. "receiver_name": "tom ochieng",
  851. "receiver_phone_number": "0715 553 018",
  852. "amount": 200,
  853. "is_cancelled": 0
  854. },
  855. {
  856. "id": 741370,
  857. "account_date": "2023-10-09",
  858. "expenses": 0,
  859. "created_at": "2023-10-09 18:43:13",
  860. "station_name": "NAIROBI",
  861. "sender_name": "peter njagi",
  862. "sender_phone_number": "0722 883 774",
  863. "receiver_name": "jackson kanambiu",
  864. "receiver_phone_number": "0714 203 693",
  865. "amount": 200,
  866. "is_cancelled": 0
  867. },
  868. {
  869. "id": 741355,
  870. "account_date": "2023-10-09",
  871. "expenses": 0,
  872. "created_at": "2023-10-09 18:32:02",
  873. "station_name": "MOMBASA",
  874. "sender_name": "mary mbugi",
  875. "sender_phone_number": "0727 897 097",
  876. "receiver_name": "elizabeth nyawira",
  877. "receiver_phone_number": "0706 221 351",
  878. "amount": 300,
  879. "is_cancelled": 0
  880. },
  881. {
  882. "id": 741351,
  883. "account_date": "2023-10-09",
  884. "expenses": 0,
  885. "created_at": "2023-10-09 18:25:01",
  886. "station_name": "NAIROBI",
  887. "sender_name": "david thairu",
  888. "sender_phone_number": "0722 280 846",
  889. "receiver_name": "daniel kimondo",
  890. "receiver_phone_number": "0724 215 522",
  891. "amount": 200,
  892. "is_cancelled": 0
  893. },
  894. {
  895. "id": 741330,
  896. "account_date": "2023-10-09",
  897. "expenses": 0,
  898. "created_at": "2023-10-09 17:58:08",
  899. "station_name": "NAIROBI",
  900. "sender_name": "karen karimi",
  901. "sender_phone_number": "0714 509 299",
  902. "receiver_name": "victor muthoni",
  903. "receiver_phone_number": "0710 329 231",
  904. "amount": 200,
  905. "is_cancelled": 0
  906. },
  907. {
  908. "id": 741328,
  909. "account_date": "2023-10-09",
  910. "expenses": 0,
  911. "created_at": "2023-10-09 17:56:42",
  912. "station_name": "KATHWANA",
  913. "sender_name": "samwel",
  914. "sender_phone_number": "0722 595 844",
  915. "receiver_name": "dirangu ndungu",
  916. "receiver_phone_number": "0721 207 371",
  917. "amount": 250,
  918. "is_cancelled": 0
  919. },
  920. {
  921. "id": 741301,
  922. "account_date": "2023-10-09",
  923. "expenses": 0,
  924. "created_at": "2023-10-09 17:35:27",
  925. "station_name": "MOMBASA",
  926. "sender_name": "edith kimani",
  927. "sender_phone_number": "0714 567 205",
  928. "receiver_name": "okumu",
  929. "receiver_phone_number": "0714 567 205",
  930. "amount": 300,
  931. "is_cancelled": 0
  932. },
  933. {
  934. "id": 741289,
  935. "account_date": "2023-10-09",
  936. "expenses": 0,
  937. "created_at": "2023-10-09 17:26:09",
  938. "station_name": "MTWAPA",
  939. "sender_name": "caroline waweru",
  940. "sender_phone_number": "0712 353 926",
  941. "receiver_name": "GLADYS WAWERU",
  942. "receiver_phone_number": "0723 950 679",
  943. "amount": 300,
  944. "is_cancelled": 0
  945. },
  946. {
  947. "id": 741270,
  948. "account_date": "2023-10-09",
  949. "expenses": 0,
  950. "created_at": "2023-10-09 17:03:24",
  951. "station_name": "MWEA",
  952. "sender_name": "FOCUS EMBU",
  953. "sender_phone_number": "0716 447 206",
  954. "receiver_name": "FOCUS MWEA",
  955. "receiver_phone_number": "0721 606 746",
  956. "amount": 150,
  957. "is_cancelled": 0
  958. },
  959. {
  960. "id": 741266,
  961. "account_date": "2023-10-09",
  962. "expenses": 0,
  963. "created_at": "2023-10-09 16:58:43",
  964. "station_name": "THIKA",
  965. "sender_name": "KIMATHI KELVIN",
  966. "sender_phone_number": "0726 116 760",
  967. "receiver_name": "JAMES NGINGI",
  968. "receiver_phone_number": "0728 171 491",
  969. "amount": 200,
  970. "is_cancelled": 0
  971. },
  972. {
  973. "id": 741261,
  974. "account_date": "2023-10-09",
  975. "expenses": 0,
  976. "created_at": "2023-10-09 16:54:46",
  977. "station_name": "NAIROBI",
  978. "sender_name": "ROSALINE WAWIRA",
  979. "sender_phone_number": "0728 288 950",
  980. "receiver_name": "eras karimi",
  981. "receiver_phone_number": "0724 867 838",
  982. "amount": 200,
  983. "is_cancelled": 0
  984. },
  985. {
  986. "id": 741248,
  987. "account_date": "2023-10-09",
  988. "expenses": 0,
  989. "created_at": "2023-10-09 16:47:18",
  990. "station_name": "MOMBASA",
  991. "sender_name": "jemmimah",
  992. "sender_phone_number": "0711 485 954",
  993. "receiver_name": "rose khaidi",
  994. "receiver_phone_number": "0706 749 991",
  995. "amount": 300,
  996. "is_cancelled": 0
  997. },
  998. {
  999. "id": 741209,
  1000. "account_date": "2023-10-09",
  1001. "expenses": 0,
  1002. "created_at": "2023-10-09 16:05:38",
  1003. "station_name": "kitui",
  1004. "sender_name": "eunice mambiro",
  1005. "sender_phone_number": "0720 997 508",
  1006. "receiver_name": "samwel muturi",
  1007. "receiver_phone_number": "0715 143 362",
  1008. "amount": 200,
  1009. "is_cancelled": 0
  1010. },
  1011. {
  1012. "id": 741185,
  1013. "account_date": "2023-10-09",
  1014. "expenses": 0,
  1015. "created_at": "2023-10-09 15:49:11",
  1016. "station_name": "NAIROBI",
  1017. "sender_name": "johnson njeru",
  1018. "sender_phone_number": "0724 586 321",
  1019. "receiver_name": "jane njoki njeru",
  1020. "receiver_phone_number": "0722 398 853",
  1021. "amount": 300,
  1022. "is_cancelled": 0
  1023. },
  1024. {
  1025. "id": 741175,
  1026. "account_date": "2023-10-09",
  1027. "expenses": 0,
  1028. "created_at": "2023-10-09 15:36:39",
  1029. "station_name": "NAIROBI",
  1030. "sender_name": "alexande david",
  1031. "sender_phone_number": "0795 762 309",
  1032. "receiver_name": "peter aunga",
  1033. "receiver_phone_number": "0715 044 187",
  1034. "amount": 200,
  1035. "is_cancelled": 0
  1036. },
  1037. {
  1038. "id": 741172,
  1039. "account_date": "2023-10-09",
  1040. "expenses": 0,
  1041. "created_at": "2023-10-09 15:33:17",
  1042. "station_name": "NAIROBI",
  1043. "sender_name": "j kimondo",
  1044. "sender_phone_number": "0722 830 014",
  1045. "receiver_name": "fred muiruri",
  1046. "receiver_phone_number": "0705 945 561",
  1047. "amount": 200,
  1048. "is_cancelled": 0
  1049. }
  1050. ]';
  1051. $arrayedFromJson = json_decode($jsonData, true);
  1052. return $arrayedFromJson;
  1053. }
  1054. /**
  1055. * @Route("/stk-push/initiate", methods={"POST"}, name="bus_stk_push_initiate")
  1056. */
  1057. public function initiateStkPush(Request $request): JsonResponse
  1058. {
  1059. $em = $this->entityManager;
  1060. $phone = $request->request->get('phone');
  1061. $amount = (float) $request->request->get('amount');
  1062. $seatNumber = $request->request->get('seat');
  1063. $tripId = $request->request->get('tripId');
  1064. if (!$phone || !$amount) {
  1065. return new JsonResponse(['success' => false, 'message' => 'Phone and amount are required.'], Response::HTTP_BAD_REQUEST);
  1066. }
  1067. $phone = preg_replace('/[^0-9]/', '', (string)$phone);
  1068. if (strpos($phone, '0') === 0) {
  1069. $phone = '254' . substr($phone, 1);
  1070. } elseif (strpos($phone, '7') === 0 || strpos($phone, '1') === 0) {
  1071. $phone = '254' . $phone;
  1072. }
  1073. /** @var MpesaAuth $mpesaAuth */
  1074. $mpesaAuth = $em->getRepository(MpesaAuth::class)->findOneBy([
  1075. 'paybill' => '4005975'
  1076. ]);
  1077. if (!$mpesaAuth) {
  1078. $mpesaAuth = $em->getRepository(MpesaAuth::class)->findOneBy([
  1079. 'shortCodeType' => 'BUS_STK'
  1080. ]);
  1081. }
  1082. if (!$mpesaAuth) {
  1083. return new JsonResponse(['success' => false, 'message' => 'M-Pesa STK configuration not found.'], Response::HTTP_INTERNAL_SERVER_ERROR);
  1084. }
  1085. $token = $this->getMpesaStkToken($mpesaAuth);
  1086. if (!$token) {
  1087. return new JsonResponse(['success' => false, 'message' => 'Failed to generate M-Pesa OAuth Access Token.'], Response::HTTP_INTERNAL_SERVER_ERROR);
  1088. }
  1089. $timestamp = (new DateTime())->format('YmdHis');
  1090. $businessShortCode = $mpesaAuth->getPaybill();
  1091. $passKey = $mpesaAuth->getPassKey();
  1092. $password = base64_encode($businessShortCode . $passKey . $timestamp);
  1093. $callbackUrl = $this->generateUrl('bus_stk_push_callback', [], UrlGeneratorInterface::ABSOLUTE_URL);
  1094. $postData = [
  1095. 'BusinessShortCode' => $businessShortCode,
  1096. 'Password' => $password,
  1097. 'Timestamp' => $timestamp,
  1098. 'TransactionType' => 'CustomerPayBillOnline',
  1099. 'Amount' => (int) $amount,
  1100. 'PartyA' => $phone,
  1101. 'PartyB' => $businessShortCode,
  1102. 'PhoneNumber' => $phone,
  1103. 'CallBackURL' => $callbackUrl,
  1104. 'AccountReference' => 'SEAT' . ($seatNumber ? '_' . $seatNumber : ''),
  1105. 'TransactionDesc' => 'Bus Booking Seat ' . $seatNumber
  1106. ];
  1107. $curl = curl_init();
  1108. curl_setopt_array($curl, [
  1109. CURLOPT_URL => "https://api.safaricom.co.ke/mpesa/stkpush/v1/processrequest",
  1110. CURLOPT_HTTPHEADER => [
  1111. "Content-Type: application/json",
  1112. "Authorization: Bearer {$token}"
  1113. ],
  1114. CURLOPT_RETURNTRANSFER => true,
  1115. CURLOPT_POST => true,
  1116. CURLOPT_POSTFIELDS => json_encode($postData),
  1117. CURLOPT_SSL_VERIFYPEER => false,
  1118. CURLOPT_TIMEOUT => 30,
  1119. ]);
  1120. $responseRaw = curl_exec($curl);
  1121. curl_close($curl);
  1122. $res = json_decode($responseRaw, true);
  1123. if (isset($res['ResponseCode']) && $res['ResponseCode'] == '0') {
  1124. $checkoutRequestId = $res['CheckoutRequestID'] ?? '';
  1125. $merchantRequestId = $res['MerchantRequestID'] ?? '';
  1126. $mpesaReq = new MpesaPaymentRequest();
  1127. $mpesaReq->setMerchantRequestId($merchantRequestId);
  1128. $mpesaReq->setCheckoutRequestId($checkoutRequestId);
  1129. $mpesaReq->setDescription("Bus Booking Seat {$seatNumber} Trip {$tripId}");
  1130. $mpesaReq->setMessage($res['CustomerMessage'] ?? $res['ResponseDescription'] ?? '');
  1131. $mpesaReq->setPhone((int) $phone);
  1132. $mpesaReq->setCreatedAt(new DateTime());
  1133. $em->persist($mpesaReq);
  1134. $em->flush();
  1135. return new JsonResponse([
  1136. 'success' => true,
  1137. 'checkoutRequestId' => $checkoutRequestId,
  1138. 'message' => 'STK Push sent to ' . $phone . '. Please enter PIN on phone.'
  1139. ]);
  1140. } else {
  1141. $errorMsg = $res['errorMessage'] ?? $res['ResponseDescription'] ?? 'Failed to send STK Push prompt.';
  1142. return new JsonResponse([
  1143. 'success' => false,
  1144. 'message' => $errorMsg
  1145. ], Response::HTTP_BAD_REQUEST);
  1146. }
  1147. }
  1148. /**
  1149. * @Route("/stk-push/callback", methods={"POST"}, name="bus_stk_push_callback")
  1150. */
  1151. public function callbackStkPush(Request $request): JsonResponse
  1152. {
  1153. $em = $this->entityManager;
  1154. $content = $request->getContent();
  1155. $data = json_decode($content, true);
  1156. $stkCallback = $data['Body']['stkCallback'] ?? null;
  1157. if ($stkCallback) {
  1158. $merchantRequestId = $stkCallback['MerchantRequestID'] ?? '';
  1159. $checkoutRequestId = $stkCallback['CheckoutRequestID'] ?? '';
  1160. $resultCode = (int) ($stkCallback['ResultCode'] ?? -1);
  1161. $resultDesc = $stkCallback['ResultDesc'] ?? '';
  1162. $mpesaResp = new MpesaResponse();
  1163. $mpesaResp->setMerchantRequestId($merchantRequestId);
  1164. $mpesaResp->setCheckoutRequestId($checkoutRequestId);
  1165. $mpesaResp->setCode($resultCode);
  1166. $mpesaResp->setDescription($resultDesc);
  1167. $mpesaResp->setCreatedAt(new DateTime());
  1168. $em->persist($mpesaResp);
  1169. if ($resultCode === 0) {
  1170. $items = $stkCallback['CallbackMetadata']['Item'] ?? [];
  1171. $receipt = '';
  1172. $amount = 0;
  1173. $phone = '';
  1174. foreach ($items as $item) {
  1175. if (($item['Name'] ?? '') === 'MpesaReceiptNumber') {
  1176. $receipt = (string) $item['Value'];
  1177. }
  1178. if (($item['Name'] ?? '') === 'Amount') {
  1179. $amount = (float) $item['Value'];
  1180. }
  1181. if (($item['Name'] ?? '') === 'PhoneNumber') {
  1182. $phone = (string) $item['Value'];
  1183. }
  1184. }
  1185. if ($receipt) {
  1186. $existing = $em->getRepository(Mpesa::class)->findOneBy(['transactionId' => $receipt]);
  1187. if (!$existing) {
  1188. $mpesa = new Mpesa();
  1189. $mpesa->setTransactionId($receipt);
  1190. $mpesa->setTransactionType('CustomerPayBillOnline');
  1191. $mpesa->setTransactionTime(date('Y-m-d H:i:s'));
  1192. $mpesa->setTransactionAmount($amount);
  1193. $mpesa->setRefNumber($checkoutRequestId);
  1194. $mpesa->setShortCode(4005975);
  1195. $mpesa->setBalance(0);
  1196. $mpesa->setMsisdn($phone);
  1197. $mpesa->setFirstName('M-Pesa');
  1198. $mpesa->setLastName('Customer');
  1199. $mpesa->setMiddleName('');
  1200. $mpesa->setCreatedAt(new DateTime());
  1201. $mpesa->setIsUsed(false);
  1202. $em->persist($mpesa);
  1203. }
  1204. }
  1205. }
  1206. $em->flush();
  1207. }
  1208. return new JsonResponse(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
  1209. }
  1210. /**
  1211. * @Route("/stk-push/status/{checkoutRequestId}", methods={"GET"}, name="bus_stk_push_status")
  1212. */
  1213. public function checkStkPushStatus(string $checkoutRequestId): JsonResponse
  1214. {
  1215. $em = $this->entityManager;
  1216. $response = $em->getRepository(MpesaResponse::class)->findOneBy([
  1217. 'checkoutRequestId' => $checkoutRequestId
  1218. ]);
  1219. if ($response) {
  1220. if ($response->getCode() === 0) {
  1221. $mpesa = $em->getRepository(Mpesa::class)->findOneBy([
  1222. 'refNumber' => $checkoutRequestId
  1223. ]);
  1224. if (!$mpesa) {
  1225. $mpesa = $em->getRepository(Mpesa::class)->findOneBy([
  1226. 'shortCode' => 4005975,
  1227. 'isUsed' => false
  1228. ], ['id' => 'DESC']);
  1229. }
  1230. return new JsonResponse([
  1231. 'status' => 'SUCCESS',
  1232. 'mpesaId' => $mpesa ? $mpesa->getId() : null,
  1233. 'receipt' => $mpesa ? $mpesa->getTransactionId() : '',
  1234. 'amount' => $mpesa ? $mpesa->getTransactionAmount() : 0,
  1235. 'message' => 'Payment received successfully!'
  1236. ]);
  1237. } else {
  1238. return new JsonResponse([
  1239. 'status' => 'FAILED',
  1240. 'message' => $response->getDescription() ?: 'Payment failed or cancelled by customer.'
  1241. ]);
  1242. }
  1243. }
  1244. return new JsonResponse(['status' => 'PENDING']);
  1245. }
  1246. private function getMpesaStkToken(MpesaAuth $mpesaAuth): ?string
  1247. {
  1248. if ($mpesaAuth->getToken() && $mpesaAuth->getTokenUpdatedAt()) {
  1249. $diff = (new DateTime())->getTimestamp() - $mpesaAuth->getTokenUpdatedAt()->getTimestamp();
  1250. if ($diff < 3000) {
  1251. return $mpesaAuth->getToken();
  1252. }
  1253. }
  1254. $url = "https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials";
  1255. $credentials = base64_encode("{$mpesaAuth->getConsumerKey()}:{$mpesaAuth->getConsumerSecret()}");
  1256. $curl = curl_init();
  1257. curl_setopt_array($curl, [
  1258. CURLOPT_URL => $url,
  1259. CURLOPT_HTTPHEADER => [
  1260. "Content-Type: application/json",
  1261. "Authorization: Basic {$credentials}"
  1262. ],
  1263. CURLOPT_RETURNTRANSFER => true,
  1264. CURLOPT_HTTPGET => true,
  1265. CURLOPT_SSL_VERIFYPEER => false,
  1266. CURLOPT_TIMEOUT => 15,
  1267. ]);
  1268. $response = curl_exec($curl);
  1269. curl_close($curl);
  1270. if ($response) {
  1271. $result = json_decode($response, true);
  1272. if (isset($result['access_token'])) {
  1273. $token = $result['access_token'];
  1274. $mpesaAuth->setToken($token);
  1275. $mpesaAuth->setTokenUpdatedAt(new DateTime());
  1276. $this->entityManager->flush();
  1277. return $token;
  1278. }
  1279. }
  1280. return $mpesaAuth->getToken();
  1281. }
  1282. /**
  1283. * @Route("/trip/{trip}/fare", methods={"GET"}, name="getTripFare")
  1284. */
  1285. public function getTripFare(Request $request, Trip $trip): JsonResponse
  1286. {
  1287. $em = $this->entityManager;
  1288. $originId = $request->query->get('origin');
  1289. $destinationId = $request->query->get('destination');
  1290. $originStop = $originId ? $em->getRepository(Stop::class)->find($originId) : null;
  1291. $destStop = $destinationId ? $em->getRepository(Stop::class)->find($destinationId) : null;
  1292. $fare = $this->getDatabaseFare($trip, $originStop, $destStop);
  1293. return new JsonResponse(['fare' => $fare]);
  1294. }
  1295. private function getDatabaseFare(Trip $trip, ?Stop $originStop = null, ?Stop $destinationStop = null): float
  1296. {
  1297. $em = $this->entityManager;
  1298. $originStation = $originStop ? $originStop->getStation() : null;
  1299. $destStation = $destinationStop ? $destinationStop->getStation() : null;
  1300. if ($originStation && $destStation) {
  1301. $fareRule = $em->getRepository(RouteFareRule::class)->findOneBy([
  1302. 'route' => $trip->getRoute(),
  1303. 'origin' => $originStation,
  1304. 'destination' => $destStation
  1305. ]);
  1306. if ($fareRule && $fareRule->getNormalFare() > 0) {
  1307. return (float) $fareRule->getNormalFare();
  1308. }
  1309. }
  1310. $fareRule = $em->getRepository(RouteFareRule::class)->findOneBy([
  1311. 'route' => $trip->getRoute()
  1312. ]);
  1313. if ($fareRule && $fareRule->getNormalFare() > 0) {
  1314. return (float) $fareRule->getNormalFare();
  1315. }
  1316. return (float) ($trip->getFare() ?: 0);
  1317. }
  1318. }