| 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 |
// Departure / return date. The booking already stores the real |
| 80 |
// trip end (`end_date`, written at booking time from the selected |
| 81 |
// departure or start + duration), so prefer that authoritative |
| 82 |
// value rather than re-deriving one — this is what keeps the |
| 83 |
// travel timeline showing the ACTUAL booked end date instead of |
| 84 |
// repeating the arrival date. Only when no stored end_date exists |
| 85 |
// do we derive it, and then the SAME way |
| 86 |
// BookingRepository::calculateEndDate does: start + (duration_days |
| 87 |
// - 1). A 5-day trip therefore ends on day 5, not day 6 — the old |
| 88 |
// "+ duration_days" was off by one and implied an extra night. |
| 89 |
$returnDate = ''; |
| 90 |
$endRaw = (string) ($source['end_date'] ?? ''); |
| 91 |
if ($endRaw === '' && $travelRaw !== '' && $trip && (int) ($trip->duration_days ?? 0) > 1) { |
| 92 |
$endRaw = date( |
| 93 |
'Y-m-d', |
| 94 |
strtotime($travelRaw . ' +' . ((int) $trip->duration_days - 1) . ' days') |
| 95 |
); |
| 96 |
} |
| 97 |
// Expose a return date only when it genuinely differs from the |
| 98 |
// arrival date. Single-day bookings store end_date == start_date, |
| 99 |
// so the timeline renders one item instead of two identical dates. |
| 100 |
if ($endRaw !== '' && $endRaw !== $travelRaw) { |
| 101 |
$returnDate = date_i18n(get_option('date_format'), strtotime($endRaw)); |
| 102 |
} |
| 103 |
|
| 104 |
$statusRaw = strtolower((string) ($source['booking_status'] ?? $source['status'] ?? '')); |
| 105 |
$statusClass = in_array($statusRaw, ['confirmed', 'completed', 'success'], true) |
| 106 |
? 'confirmed' |
| 107 |
: (in_array($statusRaw, ['cancelled'], true) ? 'cancelled' : 'pending'); |
| 108 |
|
| 109 |
$customerName = trim( |
| 110 |
(string) ($source['contact_first_name'] ?? '') |
| 111 |
. ' ' |
| 112 |
. (string) ($source['contact_last_name'] ?? '') |
| 113 |
); |
| 114 |
if ($customerName === '') { |
| 115 |
$customerName = (string) ($source['customer_name'] ?? __('Customer', 'yatra')); |
| 116 |
} |
| 117 |
|
| 118 |
$currency = SettingsService::getCurrency(); |
| 119 |
|
| 120 |
$tripFallbackTitle = (string) ($source['trip_title'] ?? __('Trip Booking', 'yatra')); |
| 121 |
|
| 122 |
$templateData = [ |
| 123 |
'company_name' => SettingsService::get('company_name', get_bloginfo('name')), |
| 124 |
'company_address' => SettingsService::get('company_address', ''), |
| 125 |
'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(), |
| 126 |
'company_email' => SettingsService::get('company_email', get_option('admin_email')), |
| 127 |
'company_phone' => SettingsService::get('company_phone', ''), |
| 128 |
'customer_name' => $customerName, |
| 129 |
'customer_email' => (string) ($source['contact_email'] ?? $source['customer_email'] ?? ''), |
| 130 |
'booking_ref' => $bookingRef, |
| 131 |
'booking_date' => $bookingDate, |
| 132 |
'booking_status' => ucfirst($statusRaw !== '' ? $statusRaw : 'pending'), |
| 133 |
'status_class' => $statusClass, |
| 134 |
'trip_title' => $trip ? ((string) ($trip->title ?? $tripFallbackTitle)) : $tripFallbackTitle, |
| 135 |
'trip_description'=> $trip ? (string) ($trip->description ?? $trip->content ?? '') : '', |
| 136 |
// Duration is duration_days/duration_nights (no `duration` column). |
| 137 |
'trip_duration' => $trip |
| 138 |
? yatra_format_duration( |
| 139 |
(int) ($trip->duration_days ?? 0), |
| 140 |
isset($trip->duration_nights) ? (int) $trip->duration_nights : null, |
| 141 |
// Hour-based day tours: "8 hours" instead of "1 day". Absent |
| 142 |
// or NULL on every day-based trip, which keeps its wording. |
| 143 |
(int) ($trip->duration_hours ?? 0) |
| 144 |
) |
| 145 |
: '', |
| 146 |
'trip_difficulty' => $trip ? (string) ($trip->difficulty_name ?? '') : '', |
| 147 |
'trip_highlights' => $trip ? ($trip->highlights ?? $trip->trip_highlights ?? '') : '', |
| 148 |
'trip_includes' => $trip ? ($trip->includes ?? $trip->trip_includes ?? '') : '', |
| 149 |
'trip_excludes' => $trip ? ($trip->excludes ?? $trip->trip_excludes ?? '') : '', |
| 150 |
'departure_location' => $trip ? (string) ($trip->departure_location ?? '') : '', |
| 151 |
'destination' => $trip ? (string) ($trip->destination ?? '') : (string) ($source['destination'] ?? ''), |
| 152 |
'travel_date' => $travelDate, |
| 153 |
'return_date' => $returnDate, |
| 154 |
'currency_symbol' => FormatHelper::getCurrencySymbol($currency), |
| 155 |
'total_amount' => yatra_format_price((float) ($source['total_amount'] ?? 0), $currency, false), |
| 156 |
'amount_paid' => yatra_format_price((float) ($source['amount_paid'] ?? 0), $currency, false), |
| 157 |
'amount_due' => yatra_format_price((float) ($source['amount_due'] ?? 0), $currency, false), |
| 158 |
'traveler_count' => (int) ($source['travelers_count'] ?? $source['traveler_count'] ?? $source['travelers'] ?? 1), |
| 159 |
'itinerary_days' => $itineraryDays, |
| 160 |
// Real trip-specific Important Information fields, same |
| 161 |
// ones the single-trip page surfaces in its "Important |
| 162 |
// Information" section. The template renders only the |
| 163 |
// rows that are non-empty — no more hardcoded "Comfortable |
| 164 |
// clothing, walking shoes, sunscreen..." boilerplate. |
| 165 |
'physical_requirements' => $trip ? (string) ($trip->physical_requirements ?? '') : '', |
| 166 |
'visa_requirements' => $trip ? (string) ($trip->visa_requirements ?? '') : '', |
| 167 |
'vaccination_requirements' => $trip ? (string) ($trip->vaccination_requirements ?? '') : '', |
| 168 |
'cancellation_policy' => $trip ? (string) ($trip->cancellation_policy ?? '') : '', |
| 169 |
'age_min' => $trip ? ($trip->age_min ?? null) : null, |
| 170 |
'age_max' => $trip ? ($trip->age_max ?? null) : null, |
| 171 |
'accommodation_type' => $trip ? (string) ($trip->accommodation_type ?? '') : '', |
| 172 |
'accommodation_details' => $trip ? (string) ($trip->accommodation_details ?? '') : '', |
| 173 |
'meal_plan' => $trip ? (string) ($trip->meal_plan ?? '') : '', |
| 174 |
'transportation_included' => (bool) ($trip->transportation_included ?? false), |
| 175 |
'pickup_location' => $trip ? (string) ($trip->pickup_location ?? '') : '', |
| 176 |
'dropoff_location' => $trip ? (string) ($trip->dropoff_location ?? '') : '', |
| 177 |
'transportation_details' => $trip ? (string) ($trip->transportation_details ?? '') : '', |
| 178 |
]; |
| 179 |
|
| 180 |
// Locale-aware font is resolved inside PdfService — do NOT |
| 181 |
// pass a `default_font` override here, otherwise the Nepali / |
| 182 |
// Hindi / Arabic fonts loaded for non-Latin locales get |
| 183 |
// bypassed and we end up with the missing-glyph rectangles |
| 184 |
// this builder was created to eliminate. |
| 185 |
return $this->pdfService->renderTemplateToPdfSafely( |
| 186 |
'pdf/itinerary.php', |
| 187 |
$templateData, |
| 188 |
['paper' => 'A4', 'orientation' => 'portrait'] |
| 189 |
); |
| 190 |
} |
| 191 |
|
| 192 |
/** |
| 193 |
* Convenience: normalize a `$payment` joined record (the shape |
| 194 |
* PaymentRepository returns for itinerary endpoints — payment row |
| 195 |
* with booking columns joined in) into the `$source` array `build()` |
| 196 |
* accepts. Saves the caller from duplicating field mapping logic. |
| 197 |
*/ |
| 198 |
public function buildFromPaymentRecord(object $payment): string |
| 199 |
{ |
| 200 |
return $this->build([ |
| 201 |
'trip_id' => $payment->trip_id ?? 0, |
| 202 |
'booking_id' => $payment->booking_id ?? 0, |
| 203 |
'created_at' => $payment->created_at ?? null, |
| 204 |
'travel_date' => $payment->travel_date ?? null, |
| 205 |
// Actual booked trip range — used for the arrival/departure |
| 206 |
// timeline. Null-safe: build() falls back to a correct |
| 207 |
// derivation when the joined record doesn't carry these. |
| 208 |
'start_date' => $payment->start_date ?? null, |
| 209 |
'end_date' => $payment->end_date ?? null, |
| 210 |
// Use the BOOKING status, not the payment status. Payment |
| 211 |
// status can be "completed" while the booking itself is |
| 212 |
// still "pending" admin confirmation — the itinerary |
| 213 |
// header should reflect the booking, not the transaction. |
| 214 |
'booking_status' => $payment->booking_status ?? $payment->status ?? null, |
| 215 |
'contact_first_name' => $payment->contact_first_name ?? null, |
| 216 |
'contact_last_name' => $payment->contact_last_name ?? null, |
| 217 |
'contact_email' => $payment->contact_email ?? $payment->customer_email ?? null, |
| 218 |
'customer_name' => $payment->customer_name ?? null, |
| 219 |
'trip_title' => $payment->trip_title ?? null, |
| 220 |
'destination' => $payment->destination ?? null, |
| 221 |
'total_amount' => $payment->booking_total_amount ?? $payment->amount ?? 0, |
| 222 |
'amount_paid' => $payment->booking_amount_paid ?? $payment->amount ?? 0, |
| 223 |
'amount_due' => $payment->booking_amount_due ?? 0, |
| 224 |
'travelers_count' => $payment->traveler_count ?? $payment->travelers_count ?? $payment->travelers ?? 1, |
| 225 |
]); |
| 226 |
} |
| 227 |
} |
| 228 |
|