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