| 1 |
<?php |
| 2 |
|
| 3 |
declare(strict_types=1); |
| 4 |
|
| 5 |
namespace Yatra\Services; |
| 6 |
|
| 7 |
use Yatra\Helpers\FormatHelper; |
| 8 |
use Yatra\Repositories\TripRepository; |
| 9 |
|
| 10 |
/** |
| 11 |
* Single source of truth for the customer-facing itinerary PDF. |
| 12 |
* |
| 13 |
* Two REST routes generate this exact same PDF — `/bookings/{id}/itinerary` |
| 14 |
* (BookingsController, used when the booking has no payment row yet) and |
| 15 |
* `/payments/{id}/itinerary` (PaymentGatewayController, used once payment |
| 16 |
* exists). Before this class, each one composed its own template-data |
| 17 |
* array, fetched its own trip, and re-implemented the same status mapping |
| 18 |
* & date formatting — three subtle drift points where the two PDFs would |
| 19 |
* disagree on the same booking (e.g. the payment-side one passed |
| 20 |
* `default_font: DejaVu Sans` which overrode the locale-aware font, |
| 21 |
* stripping every Nepali glyph in the booking-side PDF; only one path |
| 22 |
* loaded the day-by-day itinerary; etc.). |
| 23 |
* |
| 24 |
* Callers now hand this class a normalized `$source` array — extracted |
| 25 |
* from whichever data shape they have on hand (booking array vs joined |
| 26 |
* payment object) — and we build + render the PDF identically for both. |
| 27 |
*/ |
| 28 |
class ItineraryPdfBuilder |
| 29 |
{ |
| 30 |
private TripRepository $tripRepository; |
| 31 |
private PdfService $pdfService; |
| 32 |
|
| 33 |
public function __construct(?TripRepository $tripRepository = null, ?PdfService $pdfService = null) |
| 34 |
{ |
| 35 |
$this->tripRepository = $tripRepository ?? new TripRepository(); |
| 36 |
$this->pdfService = $pdfService ?? new PdfService(); |
| 37 |
} |
| 38 |
|
| 39 |
public function pdfService(): PdfService |
| 40 |
{ |
| 41 |
return $this->pdfService; |
| 42 |
} |
| 43 |
|
| 44 |
/** |
| 45 |
* Build the PDF binary for an itinerary. |
| 46 |
* |
| 47 |
* @param array<string,mixed> $source Booking-like array with at minimum |
| 48 |
* `trip_id`, `booking_id`, `travel_date`, `created_at`, |
| 49 |
* `contact_first_name`, `contact_last_name`, `contact_email`, |
| 50 |
* `customer_name`, `booking_status`, `total_amount`, |
| 51 |
* `amount_paid`, `amount_due`, `travelers_count`. Unknown keys |
| 52 |
* are ignored. |
| 53 |
* @return string Raw PDF bytes. |
| 54 |
*/ |
| 55 |
public function build(array $source): string |
| 56 |
{ |
| 57 |
$tripId = (int) ($source['trip_id'] ?? 0); |
| 58 |
$trip = null; |
| 59 |
$itineraryDays = []; |
| 60 |
if ($tripId > 0) { |
| 61 |
// findWithRelations: needed so $trip carries the itinerary-day |
| 62 |
// model for the "About this trip" sidebar. getItineraryDays |
| 63 |
// returns the timeline used by the "Travel Timeline" section. |
| 64 |
$trip = $this->tripRepository->findWithRelations($tripId) |
| 65 |
?: $this->tripRepository->find($tripId); |
| 66 |
$itineraryDays = $this->tripRepository->getItineraryDays($tripId); |
| 67 |
} |
| 68 |
|
| 69 |
$bookingId = (int) ($source['booking_id'] ?? 0); |
| 70 |
$bookingRef = $bookingId > 0 |
| 71 |
? 'YTR-' . strtoupper(str_pad((string) $bookingId, 8, '0', STR_PAD_LEFT)) |
| 72 |
: ''; |
| 73 |
|
| 74 |
$createdAt = (string) ($source['created_at'] ?? $source['booking_date'] ?? ''); |
| 75 |
$bookingDate = $createdAt !== '' ? date_i18n(get_option('date_format'), strtotime($createdAt)) : ''; |
| 76 |
$travelRaw = (string) ($source['travel_date'] ?? ''); |
| 77 |
$travelDate = $travelRaw !== '' ? date_i18n(get_option('date_format'), strtotime($travelRaw)) : ''; |
| 78 |
|
| 79 |
$returnDate = ''; |
| 80 |
if ($travelRaw !== '' && $trip && !empty($trip->duration)) { |
| 81 |
$returnDate = date_i18n( |
| 82 |
get_option('date_format'), |
| 83 |
strtotime($travelRaw . ' +' . (int) $trip->duration . ' days') |
| 84 |
); |
| 85 |
} |
| 86 |
|
| 87 |
$statusRaw = strtolower((string) ($source['booking_status'] ?? $source['status'] ?? '')); |
| 88 |
$statusClass = in_array($statusRaw, ['confirmed', 'completed', 'success'], true) |
| 89 |
? 'confirmed' |
| 90 |
: (in_array($statusRaw, ['cancelled'], true) ? 'cancelled' : 'pending'); |
| 91 |
|
| 92 |
$customerName = trim( |
| 93 |
(string) ($source['contact_first_name'] ?? '') |
| 94 |
. ' ' |
| 95 |
. (string) ($source['contact_last_name'] ?? '') |
| 96 |
); |
| 97 |
if ($customerName === '') { |
| 98 |
$customerName = (string) ($source['customer_name'] ?? __('Customer', 'yatra')); |
| 99 |
} |
| 100 |
|
| 101 |
$currency = SettingsService::getCurrency(); |
| 102 |
|
| 103 |
$tripFallbackTitle = (string) ($source['trip_title'] ?? __('Trip Booking', 'yatra')); |
| 104 |
|
| 105 |
$templateData = [ |
| 106 |
'company_name' => SettingsService::get('company_name', get_bloginfo('name')), |
| 107 |
'company_address' => SettingsService::get('company_address', ''), |
| 108 |
'company_email' => SettingsService::get('company_email', get_option('admin_email')), |
| 109 |
'company_phone' => SettingsService::get('company_phone', ''), |
| 110 |
'customer_name' => $customerName, |
| 111 |
'customer_email' => (string) ($source['contact_email'] ?? $source['customer_email'] ?? ''), |
| 112 |
'booking_ref' => $bookingRef, |
| 113 |
'booking_date' => $bookingDate, |
| 114 |
'booking_status' => ucfirst($statusRaw !== '' ? $statusRaw : 'pending'), |
| 115 |
'status_class' => $statusClass, |
| 116 |
'trip_title' => $trip ? ((string) ($trip->title ?? $tripFallbackTitle)) : $tripFallbackTitle, |
| 117 |
'trip_description'=> $trip ? (string) ($trip->description ?? $trip->content ?? '') : '', |
| 118 |
/* translators: %d: trip duration in days. */ |
| 119 |
'trip_duration' => ($trip && !empty($trip->duration)) ? sprintf(__('%d days', 'yatra'), (int) $trip->duration) : '', |
| 120 |
'trip_difficulty' => $trip ? (string) ($trip->difficulty_name ?? '') : '', |
| 121 |
'trip_highlights' => $trip ? ($trip->highlights ?? $trip->trip_highlights ?? '') : '', |
| 122 |
'trip_includes' => $trip ? ($trip->includes ?? $trip->trip_includes ?? '') : '', |
| 123 |
'trip_excludes' => $trip ? ($trip->excludes ?? $trip->trip_excludes ?? '') : '', |
| 124 |
'departure_location' => $trip ? (string) ($trip->departure_location ?? '') : '', |
| 125 |
'destination' => $trip ? (string) ($trip->destination ?? '') : (string) ($source['destination'] ?? ''), |
| 126 |
'travel_date' => $travelDate, |
| 127 |
'return_date' => $returnDate, |
| 128 |
'currency_symbol' => FormatHelper::getCurrencySymbol($currency), |
| 129 |
'total_amount' => number_format((float) ($source['total_amount'] ?? 0), 2), |
| 130 |
'amount_paid' => number_format((float) ($source['amount_paid'] ?? 0), 2), |
| 131 |
'amount_due' => number_format((float) ($source['amount_due'] ?? 0), 2), |
| 132 |
'traveler_count' => (int) ($source['travelers_count'] ?? $source['traveler_count'] ?? $source['travelers'] ?? 1), |
| 133 |
'itinerary_days' => $itineraryDays, |
| 134 |
// Real trip-specific Important Information fields, same |
| 135 |
// ones the single-trip page surfaces in its "Important |
| 136 |
// Information" section. The template renders only the |
| 137 |
// rows that are non-empty — no more hardcoded "Comfortable |
| 138 |
// clothing, walking shoes, sunscreen..." boilerplate. |
| 139 |
'physical_requirements' => $trip ? (string) ($trip->physical_requirements ?? '') : '', |
| 140 |
'visa_requirements' => $trip ? (string) ($trip->visa_requirements ?? '') : '', |
| 141 |
'vaccination_requirements' => $trip ? (string) ($trip->vaccination_requirements ?? '') : '', |
| 142 |
'cancellation_policy' => $trip ? (string) ($trip->cancellation_policy ?? '') : '', |
| 143 |
'age_min' => $trip ? ($trip->age_min ?? null) : null, |
| 144 |
'age_max' => $trip ? ($trip->age_max ?? null) : null, |
| 145 |
'accommodation_type' => $trip ? (string) ($trip->accommodation_type ?? '') : '', |
| 146 |
'accommodation_details' => $trip ? (string) ($trip->accommodation_details ?? '') : '', |
| 147 |
'meal_plan' => $trip ? (string) ($trip->meal_plan ?? '') : '', |
| 148 |
'transportation_included' => (bool) ($trip->transportation_included ?? false), |
| 149 |
'pickup_location' => $trip ? (string) ($trip->pickup_location ?? '') : '', |
| 150 |
'dropoff_location' => $trip ? (string) ($trip->dropoff_location ?? '') : '', |
| 151 |
'transportation_details' => $trip ? (string) ($trip->transportation_details ?? '') : '', |
| 152 |
]; |
| 153 |
|
| 154 |
// Locale-aware font is resolved inside PdfService — do NOT |
| 155 |
// pass a `default_font` override here, otherwise the Nepali / |
| 156 |
// Hindi / Arabic fonts loaded for non-Latin locales get |
| 157 |
// bypassed and we end up with the missing-glyph rectangles |
| 158 |
// this builder was created to eliminate. |
| 159 |
return $this->pdfService->renderTemplateToPdfSafely( |
| 160 |
'pdf/itinerary.php', |
| 161 |
$templateData, |
| 162 |
['paper' => 'A4', 'orientation' => 'portrait'] |
| 163 |
); |
| 164 |
} |
| 165 |
|
| 166 |
/** |
| 167 |
* Convenience: normalize a `$payment` joined record (the shape |
| 168 |
* PaymentRepository returns for itinerary endpoints — payment row |
| 169 |
* with booking columns joined in) into the `$source` array `build()` |
| 170 |
* accepts. Saves the caller from duplicating field mapping logic. |
| 171 |
*/ |
| 172 |
public function buildFromPaymentRecord(object $payment): string |
| 173 |
{ |
| 174 |
return $this->build([ |
| 175 |
'trip_id' => $payment->trip_id ?? 0, |
| 176 |
'booking_id' => $payment->booking_id ?? 0, |
| 177 |
'created_at' => $payment->created_at ?? null, |
| 178 |
'travel_date' => $payment->travel_date ?? null, |
| 179 |
// Use the BOOKING status, not the payment status. Payment |
| 180 |
// status can be "completed" while the booking itself is |
| 181 |
// still "pending" admin confirmation — the itinerary |
| 182 |
// header should reflect the booking, not the transaction. |
| 183 |
'booking_status' => $payment->booking_status ?? $payment->status ?? null, |
| 184 |
'contact_first_name' => $payment->contact_first_name ?? null, |
| 185 |
'contact_last_name' => $payment->contact_last_name ?? null, |
| 186 |
'contact_email' => $payment->contact_email ?? $payment->customer_email ?? null, |
| 187 |
'customer_name' => $payment->customer_name ?? null, |
| 188 |
'trip_title' => $payment->trip_title ?? null, |
| 189 |
'destination' => $payment->destination ?? null, |
| 190 |
'total_amount' => $payment->booking_total_amount ?? $payment->amount ?? 0, |
| 191 |
'amount_paid' => $payment->booking_amount_paid ?? $payment->amount ?? 0, |
| 192 |
'amount_due' => $payment->booking_amount_due ?? 0, |
| 193 |
'travelers_count' => $payment->traveler_count ?? $payment->travelers_count ?? $payment->travelers ?? 1, |
| 194 |
]); |
| 195 |
} |
| 196 |
} |
| 197 |
|