PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | app/Services/TransactionalEmailTemplateService.php +808 -29 3.0.2.8 → 3.0.16 View file →
@@ -13,8 +13,17 @@
13 13 public const TYPE_BOOKING_CONFIRMATION = 'booking_confirmation';
14 14
15 15 public const TYPE_PAYMENT_CONFIRMATION = 'payment_confirmation';
16 16
17 + /**
18 + * Partial payment received (deposit / instalment), where a balance remains.
19 + *
20 + * Opt-in: until an operator enables it, every payment keeps using
21 + * TYPE_PAYMENT_CONFIRMATION exactly as before, so existing sites see no
22 + * change. Only relevant when partial payments or deposits are switched on.
23 + */
24 + public const TYPE_PARTIAL_PAYMENT_RECEIVED = 'partial_payment_received';
25 +
17 26 public const TYPE_BOOKING_CANCELLATION = 'booking_cancellation';
18 27
19 28 public const TYPE_BOOKING_REMINDER = 'booking_reminder';
20 29
@@ -23,9 +32,59 @@
23 32 public const TYPE_ADMIN_PAYMENT_RECEIVED = 'admin_payment_received';
24 33
25 34 public const TYPE_ADMIN_BOOKING_CANCELLED = 'admin_booking_cancelled_notice';
26 35
36 + /** Trip consent request (Yatra Pro Trip Consent module). */
37 + public const TYPE_TRIP_CONSENT_REQUEST = 'trip_consent_request';
38 +
39 + /** Customer account email verification (e.g. checkout registration). */
40 + public const TYPE_CUSTOMER_EMAIL_VERIFICATION = 'customer_email_verification';
41 +
27 42 /**
43 + * Guest-checkout email verification — sent BEFORE payment when
44 + * `require_guest_email_verification` is on. The booking is held
45 + * in `pending_verification` status until the customer clicks the
46 + * magic link. Distinct from `customer_email_verification` because
47 + * (a) the recipient is not a registered user, and (b) the link
48 + * resumes the in-flight booking flow rather than completing
49 + * account registration.
50 + */
51 + public const TYPE_GUEST_EMAIL_VERIFICATION = 'guest_email_verification';
52 +
53 + /** Confirmation link sent to the NEW address when a customer changes their account email. */
54 + public const TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST = 'account_email_change_request';
55 +
56 + /** Security notice sent to the OLD address once an account email change is confirmed. */
57 + public const TYPE_ACCOUNT_EMAIL_CHANGED = 'account_email_changed';
58 +
59 + public const TYPE_BOOKING_COMPLETED = 'booking_completed';
60 +
61 + public const TYPE_BOOKING_EXPIRED_CUSTOMER = 'booking_expired_customer';
62 +
63 + public const TYPE_ADMIN_BOOKING_EXPIRED = 'admin_booking_expired';
64 +
65 + public const TYPE_SCHEDULED_PAYMENT_REMINDER = 'scheduled_payment_reminder';
66 +
67 + public const TYPE_SCHEDULED_PAYMENT_SUCCEEDED = 'scheduled_payment_succeeded';
68 +
69 + public const TYPE_SCHEDULED_PAYMENT_FAILED = 'scheduled_payment_failed';
70 +
71 + public const TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED = 'admin_scheduled_payment_failed';
72 +
73 + public const TYPE_ENQUIRY_ADMIN = 'enquiry_admin';
74 +
75 + public const TYPE_ENQUIRY_CUSTOMER_RECEIVED = 'enquiry_received';
76 +
77 + public const TYPE_ENQUIRY_CUSTOMER_RESPONSE = 'enquiry_response';
78 +
79 + public const TYPE_REVIEW_REQUEST = 'review_request';
80 +
81 + /** Abandoned checkout recovery (Yatra Pro); 3-stage sequence. */
82 + public const TYPE_ABANDONED_BOOKING_RECOVERY_FIRST = 'abandoned_booking_recovery_first';
83 + public const TYPE_ABANDONED_BOOKING_RECOVERY_SECOND = 'abandoned_booking_recovery_second';
84 + public const TYPE_ABANDONED_BOOKING_RECOVERY_FINAL = 'abandoned_booking_recovery_final';
85 +
86 + /**
28 87 * Map catalog / settings UI keys to internal render types.
29 88 */
30 89 public static function coreTemplateKeyToType(string $templateKey): ?string
31 90 {
@@ -31,13 +90,33 @@
31 90 {
32 91 $map = [
33 92 'booking_confirmation' => self::TYPE_BOOKING_CONFIRMATION,
34 93 'payment_received' => self::TYPE_PAYMENT_CONFIRMATION,
94 + 'partial_payment_received' => self::TYPE_PARTIAL_PAYMENT_RECEIVED,
35 95 'booking_cancelled' => self::TYPE_BOOKING_CANCELLATION,
36 96 'trip_reminder' => self::TYPE_BOOKING_REMINDER,
37 97 'admin_new_booking' => self::TYPE_ADMIN_NEW_BOOKING,
38 98 'admin_payment_received' => self::TYPE_ADMIN_PAYMENT_RECEIVED,
39 99 'admin_booking_cancelled' => self::TYPE_ADMIN_BOOKING_CANCELLED,
100 + 'trip_consent_request' => self::TYPE_TRIP_CONSENT_REQUEST,
101 + 'customer_email_verification' => self::TYPE_CUSTOMER_EMAIL_VERIFICATION,
102 + 'guest_email_verification' => self::TYPE_GUEST_EMAIL_VERIFICATION,
103 + 'account_email_change_request' => self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST,
104 + 'account_email_changed' => self::TYPE_ACCOUNT_EMAIL_CHANGED,
105 + 'booking_completed' => self::TYPE_BOOKING_COMPLETED,
106 + 'booking_expired_customer' => self::TYPE_BOOKING_EXPIRED_CUSTOMER,
107 + 'admin_booking_expired' => self::TYPE_ADMIN_BOOKING_EXPIRED,
108 + 'scheduled_payment_reminder' => self::TYPE_SCHEDULED_PAYMENT_REMINDER,
109 + 'scheduled_payment_succeeded' => self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED,
110 + 'scheduled_payment_failed' => self::TYPE_SCHEDULED_PAYMENT_FAILED,
111 + 'admin_scheduled_payment_failed' => self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED,
112 + 'enquiry_admin' => self::TYPE_ENQUIRY_ADMIN,
113 + 'enquiry_received' => self::TYPE_ENQUIRY_CUSTOMER_RECEIVED,
114 + 'enquiry_response' => self::TYPE_ENQUIRY_CUSTOMER_RESPONSE,
115 + 'review_request' => self::TYPE_REVIEW_REQUEST,
116 + 'abandoned_booking_recovery_first' => self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST,
117 + 'abandoned_booking_recovery_second' => self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND,
118 + 'abandoned_booking_recovery_final' => self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL,
40 119 ];
41 120
42 121 return $map[$templateKey] ?? null;
43 122 }
@@ -81,8 +160,9 @@
81 160 */
82 161 public static function renderWithStringTemplates(string $type, string $subjectTpl, string $bodyTpl, array $variables): array
83 162 {
84 163 $variables = self::mergeDefaultVariables($variables);
164 + $variables = self::normalizeVariablesForType($type, $variables);
85 165 $map = self::typeToSettingsKeys();
86 166 if (!isset($map[$type])) {
87 167 return ['subject' => '', 'body' => ''];
88 168 }
@@ -87,9 +167,21 @@
87 167 return ['subject' => '', 'body' => ''];
88 168 }
89 169
90 170 if ($subjectTpl === '') {
171 + // No operator-configured subject → use the built-in default, unless
172 + // a caller supplied a context-specific override (e.g. guest checkout
173 + // substitutes its booking-oriented subject for the account default).
174 + // This is a FALLBACK only: when the operator HAS configured a subject
175 + // (the `else` branch) it always wins — otherwise `_subject_override`
176 + // would clobber a configured subject with the generic default.
91 177 $subject = self::defaultSubject($type, $variables);
178 + if (isset($variables['_subject_override'])
179 + && is_string($variables['_subject_override'])
180 + && $variables['_subject_override'] !== ''
181 + ) {
182 + $subject = $variables['_subject_override'];
183 + }
92 184 } else {
93 185 $subject = self::parseTemplate($subjectTpl, $variables);
94 186 }
95 187
@@ -109,9 +201,9 @@
109 201 * @return array<string, string>
110 202 */
111 203 private static function typeToSettingsKeys(): array
112 204 {
113 - return [
205 + $defaults = [
114 206 self::TYPE_BOOKING_CONFIRMATION => [
115 207 'flag' => 'email_template_booking',
116 208 'subject' => 'email_tpl_booking_subject',
117 209 'body' => 'email_tpl_booking_body',
@@ -120,8 +212,13 @@
120 212 'flag' => 'email_template_confirmation',
121 213 'subject' => 'email_tpl_payment_subject',
122 214 'body' => 'email_tpl_payment_body',
123 215 ],
216 + self::TYPE_PARTIAL_PAYMENT_RECEIVED => [
217 + 'flag' => 'email_template_partial_payment',
218 + 'subject' => 'email_tpl_partial_payment_subject',
219 + 'body' => 'email_tpl_partial_payment_body',
220 + ],
124 221 self::TYPE_BOOKING_CANCELLATION => [
125 222 'flag' => 'email_template_cancellation',
126 223 'subject' => 'email_tpl_cancellation_subject',
127 224 'body' => 'email_tpl_cancellation_body',
@@ -145,12 +242,233 @@
145 242 'flag' => 'email_template_admin_cancellation',
146 243 'subject' => 'email_tpl_admin_cancellation_subject',
147 244 'body' => 'email_tpl_admin_cancellation_body',
148 245 ],
246 + self::TYPE_TRIP_CONSENT_REQUEST => [
247 + 'flag' => 'email_template_trip_consent',
248 + 'subject' => 'email_tpl_trip_consent_subject',
249 + 'body' => 'email_tpl_trip_consent_body',
250 + ],
251 + self::TYPE_CUSTOMER_EMAIL_VERIFICATION => [
252 + 'flag' => 'email_template_customer_verification',
253 + 'subject' => 'email_tpl_customer_verification_subject',
254 + 'body' => 'email_tpl_customer_verification_body',
255 + ],
256 + self::TYPE_GUEST_EMAIL_VERIFICATION => [
257 + 'flag' => 'email_template_guest_verification',
258 + 'subject' => 'email_tpl_guest_verification_subject',
259 + 'body' => 'email_tpl_guest_verification_body',
260 + ],
261 + self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST => [
262 + 'flag' => 'email_template_account_email_change',
263 + 'subject' => 'email_tpl_account_email_change_subject',
264 + 'body' => 'email_tpl_account_email_change_body',
265 + ],
266 + self::TYPE_ACCOUNT_EMAIL_CHANGED => [
267 + 'flag' => 'email_template_account_email_changed',
268 + 'subject' => 'email_tpl_account_email_changed_subject',
269 + 'body' => 'email_tpl_account_email_changed_body',
270 + ],
271 + self::TYPE_BOOKING_COMPLETED => [
272 + 'flag' => 'email_template_booking_completed',
273 + 'subject' => 'email_tpl_booking_completed_subject',
274 + 'body' => 'email_tpl_booking_completed_body',
275 + ],
276 + self::TYPE_BOOKING_EXPIRED_CUSTOMER => [
277 + 'flag' => 'email_template_booking_expired_customer',
278 + 'subject' => 'email_tpl_booking_expired_customer_subject',
279 + 'body' => 'email_tpl_booking_expired_customer_body',
280 + ],
281 + self::TYPE_ADMIN_BOOKING_EXPIRED => [
282 + 'flag' => 'email_template_admin_booking_expired',
283 + 'subject' => 'email_tpl_admin_booking_expired_subject',
284 + 'body' => 'email_tpl_admin_booking_expired_body',
285 + ],
286 + self::TYPE_SCHEDULED_PAYMENT_REMINDER => [
287 + 'flag' => 'email_template_scheduled_payment_reminder',
288 + 'subject' => 'email_tpl_scheduled_payment_reminder_subject',
289 + 'body' => 'email_tpl_scheduled_payment_reminder_body',
290 + ],
291 + self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED => [
292 + 'flag' => 'email_template_scheduled_payment_succeeded',
293 + 'subject' => 'email_tpl_scheduled_payment_succeeded_subject',
294 + 'body' => 'email_tpl_scheduled_payment_succeeded_body',
295 + ],
296 + self::TYPE_SCHEDULED_PAYMENT_FAILED => [
297 + 'flag' => 'email_template_scheduled_payment_failed',
298 + 'subject' => 'email_tpl_scheduled_payment_failed_subject',
299 + 'body' => 'email_tpl_scheduled_payment_failed_body',
300 + ],
301 + self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED => [
302 + 'flag' => 'email_template_admin_scheduled_payment_failed',
303 + 'subject' => 'email_tpl_admin_scheduled_payment_failed_subject',
304 + 'body' => 'email_tpl_admin_scheduled_payment_failed_body',
305 + ],
306 + self::TYPE_ENQUIRY_ADMIN => [
307 + 'flag' => 'email_template_enquiry_admin',
308 + 'subject' => 'email_tpl_enquiry_admin_subject',
309 + 'body' => 'email_tpl_enquiry_admin_body',
310 + ],
311 + self::TYPE_ENQUIRY_CUSTOMER_RECEIVED => [
312 + 'flag' => 'email_template_enquiry_received',
313 + 'subject' => 'email_tpl_enquiry_received_subject',
314 + 'body' => 'email_tpl_enquiry_received_body',
315 + ],
316 + self::TYPE_ENQUIRY_CUSTOMER_RESPONSE => [
317 + 'flag' => 'email_template_enquiry_response',
318 + 'subject' => 'email_tpl_enquiry_response_subject',
319 + 'body' => 'email_tpl_enquiry_response_body',
320 + ],
321 + self::TYPE_REVIEW_REQUEST => [
322 + 'flag' => 'email_template_review_request',
323 + 'subject' => 'email_tpl_review_request_subject',
324 + 'body' => 'email_tpl_review_request_body',
325 + ],
326 + self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST => [
327 + 'flag' => 'email_template_abandoned_booking_recovery_first',
328 + 'subject' => 'email_tpl_abandoned_booking_recovery_first_subject',
329 + 'body' => 'email_tpl_abandoned_booking_recovery_first_body',
330 + ],
331 + self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND => [
332 + 'flag' => 'email_template_abandoned_booking_recovery_second',
333 + 'subject' => 'email_tpl_abandoned_booking_recovery_second_subject',
334 + 'body' => 'email_tpl_abandoned_booking_recovery_second_body',
335 + ],
336 + self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL => [
337 + 'flag' => 'email_template_abandoned_booking_recovery_final',
338 + 'subject' => 'email_tpl_abandoned_booking_recovery_final_subject',
339 + 'body' => 'email_tpl_abandoned_booking_recovery_final_body',
340 + ],
149 341 ];
342 +
343 + /**
344 + * Allow Pro modules (Team & Access, etc.) to register additional
345 + * transactional template types — each entry must be an array with
346 + * `flag`, `subject`, `body` keys matching the option-name pattern
347 + * used above. Once registered, the type participates in:
348 + * - sendIfEnabled() (flag gate + send)
349 + * - render() / renderWithStringTemplates() (templated subject/body)
350 + * - the Email → Templates UI (auto-discovered via the same map)
351 + *
352 + * Modules also need to hook `yatra_transactional_email_default_subject`
353 + * and `..._default_body` to supply baseline copy for their type.
354 + *
355 + * @param array<string, array{flag:string,subject:string,body:string}> $defaults
356 + */
357 + // Per-template BCC / CC keys are DERIVED from each type's subject key
358 + // (email_tpl_booking_subject -> email_tpl_booking_bcc / _cc) rather than
359 + // written out 26 times. A hand-maintained parallel list is exactly how
360 + // `admin_payment_received` ended up missing from the Pro override map, so
361 + // a new template type now gets its BCC/CC keys automatically — including
362 + // types added by modules through the filter below.
363 + foreach ($defaults as $type => $keys) {
364 + if (empty($keys['subject']) || !is_string($keys['subject'])) {
365 + continue;
366 + }
367 +
368 + $base = preg_replace('/_subject$/', '', $keys['subject']);
369 +
370 + if (!isset($defaults[$type]['bcc'])) {
371 + $defaults[$type]['bcc'] = $base . '_bcc';
372 + }
373 + if (!isset($defaults[$type]['cc'])) {
374 + $defaults[$type]['cc'] = $base . '_cc';
375 + }
376 + }
377 +
378 + return (array) apply_filters('yatra_transactional_email_type_to_keys', $defaults);
150 379 }
151 380
152 381 /**
382 + * Build Cc/Bcc headers for a transactional type from its own settings.
383 + *
384 + * Both are opt-in: an empty setting adds no header, so nothing changes for
385 + * an operator who never fills them in. Multiple comma-separated addresses are
386 + * supported, and anything that is not a valid address is dropped rather than
387 + * handed to the mailer.
388 + *
389 + * @return string[]
390 + */
391 + /**
392 + * The transactional type currently being dispatched, if any.
393 + *
394 + * Pro can take over a send through `yatra_send_transactional_email` and mails
395 + * it through its own service, which means header building here would be
396 + * skipped entirely. Both paths funnel through EmailService::send, so the type
397 + * is recorded for the duration of the dispatch and the Cc/Bcc for that
398 + * template is applied there — one injection point that works whether core or
399 + * Pro actually sends.
400 + *
401 + * @var string
402 + */
403 + private static $dispatchingType = '';
404 +
405 + /**
406 + * Cc/Bcc headers for the send currently in flight, for EmailService.
407 + *
408 + * @return string[]
409 + */
410 + public static function headersForCurrentDispatch(): array
411 + {
412 + if (self::$dispatchingType === '') {
413 + return [];
414 + }
415 +
416 + return self::recipientHeadersForType(self::$dispatchingType);
417 + }
418 +
419 + private static function recipientHeadersForType(string $type): array
420 + {
421 + $map = self::typeToSettingsKeys();
422 +
423 + if (!isset($map[$type])) {
424 + return [];
425 + }
426 +
427 + $headers = [];
428 +
429 + foreach (['Cc' => $map[$type]['cc'] ?? '', 'Bcc' => $map[$type]['bcc'] ?? ''] as $label => $settingKey) {
430 + if ($settingKey === '') {
431 + continue;
432 + }
433 +
434 + $addresses = self::sanitizeAddressList((string) SettingsService::get($settingKey, ''));
435 +
436 + if ($addresses !== []) {
437 + $headers[] = $label . ': ' . implode(', ', $addresses);
438 + }
439 + }
440 +
441 + return $headers;
442 + }
443 +
444 + /**
445 + * Split a comma/semicolon separated address list into valid addresses.
446 + *
447 + * @return string[]
448 + */
449 + public static function sanitizeAddressList(string $raw): array
450 + {
451 + $raw = trim($raw);
452 +
453 + if ($raw === '') {
454 + return [];
455 + }
456 +
457 + $addresses = [];
458 +
459 + foreach (preg_split('/[,;]+/', $raw) as $candidate) {
460 + $candidate = sanitize_email(trim((string) $candidate));
461 +
462 + if ($candidate !== '' && is_email($candidate)) {
463 + $addresses[strtolower($candidate)] = $candidate;
464 + }
465 + }
466 +
467 + return array_values($addresses);
468 + }
469 +
470 + /**
153 471 * Send if the type is enabled in settings. Pro may handle via {@see 'yatra_send_transactional_email'}.
154 472 *
155 473 * Optional string `transactional_context` (e.g. `booking_created`, `status_confirmed`) is passed through
156 474 * to the filter so Pro can choose a different template row for the same TYPE_BOOKING_CONFIRMATION.
@@ -176,32 +494,41 @@
176 494 return false;
177 495 }
178 496
179 497 $variables = self::mergeDefaultVariables($variables);
498 + $variables = self::normalizeVariablesForType($type, $variables);
180 499
181 500 /**
182 501 * Allow Yatra Pro (or extensions) to send instead of core templates.
183 502 * Return null to use core; true/false if handled.
184 - *
185 - * @param null|bool $handled
186 503 */
187 - $handled = apply_filters('yatra_send_transactional_email', null, $type, $to, $variables);
188 - if ($handled !== null) {
189 - return (bool) $handled;
190 - }
504 + // Mark the type for the whole dispatch — including a Pro takeover — so
505 + // EmailService can apply this template's own Cc/Bcc whichever service
506 + // ends up doing the sending.
507 + $previousType = self::$dispatchingType;
508 + self::$dispatchingType = $type;
191 509
192 - if (!SettingsService::isEnabled($flag)) {
193 - return false;
194 - }
510 + try {
511 + $handled = apply_filters('yatra_send_transactional_email', null, $type, $to, $variables);
512 + if ($handled !== null) {
513 + return (bool) $handled;
514 + }
195 515
196 - $rendered = self::render($type, $variables);
516 + if (!SettingsService::isEnabled($flag)) {
517 + return false;
518 + }
197 519
198 - return EmailService::send(
199 - $to,
200 - $rendered['subject'],
201 - $rendered['body'],
202 - ['Content-Type: text/html; charset=UTF-8']
203 - );
520 + $rendered = self::render($type, $variables);
521 +
522 + return EmailService::send(
523 + $to,
524 + $rendered['subject'],
525 + $rendered['body'],
526 + ['Content-Type: text/html; charset=UTF-8']
527 + );
528 + } finally {
529 + self::$dispatchingType = $previousType;
530 + }
204 531 }
205 532
206 533 /**
207 534 * @param array<string, string|int|float> $variables
@@ -223,8 +550,38 @@
223 550 return self::renderWithStringTemplates($type, $subjectTpl, $bodyTpl, $variables);
224 551 }
225 552
226 553 /**
554 + * Would the template that actually gets sent for $type render the
555 + * verification link ({{verification_link}})? Guest checkout can't complete
556 + * without it, so the checkout controller uses this to decide whether an
557 + * operator's customised verification template is safe to use, or whether to
558 + * fall back to the built-in default. Respects Pro ownership: a Pro DB
559 + * template reports its raw body via `yatra_transactional_email_effective_body`;
560 + * otherwise the core option body is checked, and an empty option means the
561 + * built-in default (which always includes the link) is used.
562 + */
563 + public static function templateRendersVerificationLink(string $type): bool
564 + {
565 + $effective = apply_filters('yatra_transactional_email_effective_body', null, $type);
566 + if (is_string($effective) && $effective !== '') {
567 + return strpos($effective, 'verification_link') !== false;
568 + }
569 +
570 + $map = self::typeToSettingsKeys();
571 + if (!isset($map[$type])) {
572 + return false;
573 + }
574 +
575 + $body = SettingsService::getString($map[$type]['body'], '');
576 + if (trim($body) === '') {
577 + return true; // no custom body → built-in default is used, which always carries the link
578 + }
579 +
580 + return strpos($body, 'verification_link') !== false;
581 + }
582 +
583 + /**
227 584 * @param array<string, string|int|float> $variables
228 585 * @return array<string, string>
229 586 */
230 587 private static function mergeDefaultVariables(array $variables): array
@@ -243,21 +600,117 @@
243 600 return $out;
244 601 }
245 602
246 603 /**
604 + * Ensure templates always have safe, meaningful defaults for commonly-used tags.
605 + *
606 + * This prevents "blank sections" when a caller supplies only the core booking variables
607 + * (e.g. status-change emails) while the template contains richer optional sections.
608 + *
247 609 * @param array<string, string> $variables
610 + * @return array<string, string>
248 611 */
612 + private static function normalizeVariablesForType(string $type, array $variables): array
613 + {
614 + // Booking confirmation is sent from multiple contexts (checkout + admin status changes).
615 + // If the caller didn't include the rich "intro/details/footer" blocks, provide a minimal,
616 + // data-driven fallback so the email still looks correct.
617 + if ($type === self::TYPE_BOOKING_CONFIRMATION) {
618 + if (!isset($variables['intro_paragraph']) || trim($variables['intro_paragraph']) === '') {
619 + $variables['intro_paragraph'] = __('Thank you for your booking.', 'yatra');
620 + }
621 + if (!isset($variables['details_html']) || trim($variables['details_html']) === '') {
622 + $variables['details_html'] = self::fallbackBookingDetailsHtml($variables);
623 + }
624 + if (!isset($variables['footer_note']) || trim($variables['footer_note']) === '') {
625 + /* translators: %s: site name. */
626 + $variables['footer_note'] = sprintf(__('— %s', 'yatra'), get_bloginfo('name'));
627 + }
628 + }
629 +
630 + // Shared defaults that are safe for most templates if included.
631 + if (!isset($variables['intro_paragraph'])) {
632 + $variables['intro_paragraph'] = '';
633 + }
634 + if (!isset($variables['footer_note'])) {
635 + $variables['footer_note'] = '';
636 + }
637 + if (!isset($variables['details_html'])) {
638 + $variables['details_html'] = '';
639 + }
640 +
641 + return $variables;
642 + }
643 +
644 + /**
645 + * Minimal booking details block for confirmation emails when caller doesn't provide `details_html`.
646 + *
647 + * @param array<string, string> $v
648 + */
649 + private static function fallbackBookingDetailsHtml(array $v): string
650 + {
651 + $trip = $v['trip_name'] ?? '';
652 + $date = $v['travel_date'] ?? '';
653 + $pax = $v['travelers_count'] ?? '';
654 + $total = $v['total_amount_formatted'] ?? '';
655 + $due = $v['amount_due_formatted'] ?? '';
656 +
657 + $rows = [];
658 + if ($trip !== '') {
659 + $rows[] = ['label' => __('Trip', 'yatra'), 'value' => esc_html($trip)];
660 + }
661 + if ($date !== '') {
662 + $rows[] = ['label' => __('Departure', 'yatra'), 'value' => esc_html($date)];
663 + }
664 + if ($pax !== '') {
665 + $rows[] = ['label' => __('Travelers', 'yatra'), 'value' => esc_html($pax)];
666 + }
667 + if ($total !== '') {
668 + $rows[] = ['label' => __('Total', 'yatra'), 'value' => esc_html($total)];
669 + }
670 + if ($due !== '') {
671 + $rows[] = ['label' => __('Amount due', 'yatra'), 'value' => esc_html($due)];
672 + }
673 +
674 + if (empty($rows)) {
675 + return '';
676 + }
677 +
678 + return EmailTemplateLayout::detailCard($rows);
679 + }
680 +
681 + /**
682 + * @param array<string, string> $variables
683 + */
249 684 private static function parseTemplate(string $template, array $variables): string
250 685 {
251 - return (string) preg_replace_callback(
252 - '/\{\{(\w+)\}\}/',
686 + $rendered = (string) preg_replace_callback(
687 + // Allow optional whitespace: {{trip_name}} and {{ trip_name }} both work.
688 + '/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/',
253 689 static function (array $m) use ($variables): string {
254 690 $key = $m[1];
255 691
256 - return $variables[$key] ?? $m[0];
692 + // Never leak raw merge-tags into real emails. If a variable is
693 + // missing, replace it with an empty string rather than
694 + // returning the original {{tag}} token.
695 + return $variables[$key] ?? '';
257 696 },
258 697 $template
259 698 );
699 +
700 + // Hard-strip any remaining merge-tags (defense-in-depth).
701 + $rendered = (string) preg_replace('/\{\{\s*[a-zA-Z0-9_]+\s*\}\}/', '', $rendered);
702 +
703 + // Users sometimes paste helper text from the editor into the template.
704 + // If that happens, strip common helper headings so they don't appear in
705 + // production emails.
706 + $rendered = (string) preg_replace(
707 + '/^.*(Available Variables|Available placeholders|Available Placeholders|Merge tags).*$/mi',
708 + '',
709 + $rendered
710 + );
711 +
712 + return $rendered;
260 713 }
261 714
262 715 /**
263 716 * @param array<string, string> $v
@@ -268,29 +721,139 @@
268 721 $ref = $v['booking_reference'] ?? $v['booking_id'] ?? '';
269 722
270 723 switch ($type) {
271 724 case self::TYPE_BOOKING_CONFIRMATION:
272 - return sprintf(__('✈️ [%s] Booking update · %s', 'yatra'), $site, $ref);
725 + /* translators: 1: site name, 2: booking reference. */
726 + return sprintf(__('✈️ [%1$s] Booking update · %2$s', 'yatra'), $site, $ref);
273 727
274 728 case self::TYPE_PAYMENT_CONFIRMATION:
275 - return sprintf(__('✅ [%s] Payment received · %s', 'yatra'), $site, $ref);
729 + /* translators: 1: site name, 2: booking reference. */
730 + return sprintf(__('✅ [%1$s] Payment received · %2$s', 'yatra'), $site, $ref);
276 731
732 + case self::TYPE_PARTIAL_PAYMENT_RECEIVED:
733 + /* translators: 1: site name, 2: booking reference. */
734 + return sprintf(__('💳 [%1$s] Part payment received · %2$s', 'yatra'), $site, $ref);
735 +
277 736 case self::TYPE_BOOKING_CANCELLATION:
278 - return sprintf(__('📋 [%s] Booking cancelled · %s', 'yatra'), $site, $ref);
737 + /* translators: 1: site name, 2: booking reference. */
738 + return sprintf(__('📋 [%1$s] Booking cancelled · %2$s', 'yatra'), $site, $ref);
279 739
280 740 case self::TYPE_BOOKING_REMINDER:
281 - return sprintf(__('🗓️ [%s] Your trip is coming up · %s', 'yatra'), $site, $ref);
741 + /* translators: 1: site name, 2: booking reference. */
742 + return sprintf(__('🗓️ [%1$s] Your trip is coming up · %2$s', 'yatra'), $site, $ref);
282 743
283 744 case self::TYPE_ADMIN_NEW_BOOKING:
284 - return sprintf(__('🔔 [%s] New booking · %s (#%s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
745 + /* translators: 1: site name, 2: booking reference, 3: booking ID. */
746 + return sprintf(__('🔔 [%1$s] New booking · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
285 747
286 748 case self::TYPE_ADMIN_PAYMENT_RECEIVED:
287 - return sprintf(__('✅ [%s] Payment received · %s (#%s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
749 + /* translators: 1: site name, 2: booking reference, 3: booking ID. */
750 + return sprintf(__('✅ [%1$s] Payment received · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
288 751
289 752 case self::TYPE_ADMIN_BOOKING_CANCELLED:
290 - return sprintf(__('📋 [%s] Booking cancelled · %s (#%s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
753 + /* translators: 1: site name, 2: booking reference, 3: booking ID. */
754 + return sprintf(__('📋 [%1$s] Booking cancelled · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
291 755
756 + case self::TYPE_TRIP_CONSENT_REQUEST:
757 + $formName = $v['form_name'] ?? __('consent form', 'yatra');
758 +
759 + /* translators: 1: site name, 2: consent form name. */
760 + return sprintf(__('📝 [%1$s] Action required · %2$s', 'yatra'), $site, $formName);
761 +
762 + case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
763 + /* translators: %s: site name. */
764 + return sprintf(__('✉️ [%s] Verify your email address', 'yatra'), $site);
765 +
766 + case self::TYPE_GUEST_EMAIL_VERIFICATION:
767 + // Distinct subject so customers can tell apart "verify
768 + // your account" from "verify to complete your booking".
769 + /* translators: %s: site name. */
770 + return sprintf(__('✉️ [%s] Verify your email to complete your booking', 'yatra'), $site);
771 +
772 + case self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST:
773 + /* translators: %s: site name. */
774 + return sprintf(__('✉️ [%s] Confirm your new email address', 'yatra'), $site);
775 +
776 + case self::TYPE_ACCOUNT_EMAIL_CHANGED:
777 + /* translators: %s: site name. */
778 + return sprintf(__('🔔 [%s] Your email address was changed', 'yatra'), $site);
779 +
780 + case self::TYPE_BOOKING_COMPLETED:
781 + /* translators: 1: site name, 2: booking reference. */
782 + return sprintf(__('🌟 [%1$s] Trip complete · %2$s', 'yatra'), $site, $ref);
783 +
784 + case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
785 + /* translators: 1: site name, 2: booking reference. */
786 + return sprintf(__('⏱️ [%1$s] Booking expired · %2$s', 'yatra'), $site, $ref);
787 +
788 + case self::TYPE_ADMIN_BOOKING_EXPIRED:
789 + /* translators: 1: site name, 2: booking reference, 3: booking ID. */
790 + return sprintf(__('⏱️ [%1$s] Booking expired · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
791 +
792 + case self::TYPE_SCHEDULED_PAYMENT_REMINDER:
793 + /* translators: 1: site name, 2: booking reference. */
794 + return sprintf(__('💳 [%1$s] Upcoming payment · %2$s', 'yatra'), $site, $ref);
795 +
796 + case self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED:
797 + /* translators: 1: site name, 2: booking reference. */
798 + return sprintf(__('✅ [%1$s] Scheduled payment received · %2$s', 'yatra'), $site, $ref);
799 +
800 + case self::TYPE_SCHEDULED_PAYMENT_FAILED:
801 + /* translators: 1: site name, 2: booking reference. */
802 + return sprintf(__('⚠️ [%1$s] Payment issue · %2$s', 'yatra'), $site, $ref);
803 +
804 + case self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED:
805 + /* translators: 1: site name, 2: booking reference. */
806 + return sprintf(__('⚠️ [%1$s] Scheduled payment failed · %2$s', 'yatra'), $site, $ref);
807 +
808 + case self::TYPE_ENQUIRY_ADMIN:
809 + $who = $v['customer_name'] ?? __('Customer', 'yatra');
810 +
811 + /* translators: 1: site name, 2: customer name. */
812 + return sprintf(__('💬 [%1$s] New enquiry · %2$s', 'yatra'), $site, $who);
813 +
814 + case self::TYPE_ENQUIRY_CUSTOMER_RECEIVED:
815 + /* translators: %s: site name. */
816 + return sprintf(__('✉️ [%s] We received your message', 'yatra'), $site);
817 +
818 + case self::TYPE_ENQUIRY_CUSTOMER_RESPONSE:
819 + /* translators: %s: site name. */
820 + return sprintf(__('💬 [%s] Re: your enquiry', 'yatra'), $site);
821 +
822 + case self::TYPE_REVIEW_REQUEST:
823 + $trip = $v['trip_name'] ?? __('your trip', 'yatra');
824 +
825 + /* translators: 1: site name, 2: trip name. */
826 + return sprintf(__('⭐ [%1$s] How was %2$s?', 'yatra'), $site, $trip);
827 +
828 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
829 + /* translators: %s: site name. */
830 + return sprintf(__('🛒 [%s] Complete your booking', 'yatra'), $site);
831 +
832 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND:
833 + /* translators: %s: site name. */
834 + return sprintf(__('⏳ [%s] Still interested? Your booking is waiting', 'yatra'), $site);
835 +
836 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL:
837 + /* translators: %s: site name. */
838 + return sprintf(__('⚠️ [%s] Final reminder: complete your booking', 'yatra'), $site);
839 +
292 840 default:
841 + // Pro modules register their own types via
842 + // `yatra_transactional_email_type_to_keys` — they
843 + // supply default copy through this filter. Returning
844 + // empty string means "no extension claimed this type"
845 + // and we fall back to the generic notification line.
846 + $custom = (string) apply_filters(
847 + 'yatra_transactional_email_default_subject',
848 + '',
849 + $type,
850 + $v
851 + );
852 + if ($custom !== '') {
853 + return $custom;
854 + }
855 + /* translators: %s: site name. */
293 856 return sprintf(__('✉️ [%s] Notification', 'yatra'), $site);
294 857 }
295 858 }
296 859
@@ -305,8 +868,11 @@
305 868
306 869 case self::TYPE_PAYMENT_CONFIRMATION:
307 870 return EmailTemplateDefaults::fallbackTransactionalPayment($v);
308 871
872 + case self::TYPE_PARTIAL_PAYMENT_RECEIVED:
873 + return EmailTemplateDefaults::fallbackTransactionalPartialPayment($v);
874 +
309 875 case self::TYPE_BOOKING_CANCELLATION:
310 876 return EmailTemplateDefaults::fallbackTransactionalCancellation($v);
311 877
312 878 case self::TYPE_BOOKING_REMINDER:
@@ -320,9 +886,87 @@
320 886
321 887 case self::TYPE_ADMIN_BOOKING_CANCELLED:
322 888 return EmailTemplateDefaults::fallbackAdminBookingCancelled($v);
323 889
890 + case self::TYPE_TRIP_CONSENT_REQUEST:
891 + return EmailTemplateDefaults::fallbackTransactionalTripConsent($v);
892 +
893 + case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
894 + return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v);
895 +
896 + case self::TYPE_GUEST_EMAIL_VERIFICATION:
897 + // Reuse the customer-verification body. The flow is
898 + // similar — click a magic link to prove ownership of
899 + // the address — and operators that have already
900 + // customised the customer-verification copy get a
901 + // consistent look across both. Differentiating copy is
902 + // injected at call-time via the intro_paragraph /
903 + // footer_note merge tags by the booking handler.
904 + return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v);
905 +
906 + case self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST:
907 + return EmailTemplateDefaults::fallbackTransactionalAccountEmailChangeRequest($v);
908 +
909 + case self::TYPE_ACCOUNT_EMAIL_CHANGED:
910 + return EmailTemplateDefaults::fallbackTransactionalAccountEmailChanged($v);
911 +
912 + case self::TYPE_BOOKING_COMPLETED:
913 + return EmailTemplateDefaults::fallbackTransactionalBookingCompleted($v);
914 +
915 + case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
916 + return EmailTemplateDefaults::fallbackTransactionalBookingExpiredCustomer($v);
917 +
918 + case self::TYPE_ADMIN_BOOKING_EXPIRED:
919 + return EmailTemplateDefaults::fallbackAdminBookingExpired($v);
920 +
921 + case self::TYPE_SCHEDULED_PAYMENT_REMINDER:
922 + return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentReminder($v);
923 +
924 + case self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED:
925 + return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentSucceeded($v);
926 +
927 + case self::TYPE_SCHEDULED_PAYMENT_FAILED:
928 + return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentFailed($v);
929 +
930 + case self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED:
931 + return EmailTemplateDefaults::fallbackAdminScheduledPaymentFailed($v);
932 +
933 + case self::TYPE_ENQUIRY_ADMIN:
934 + return EmailTemplateDefaults::fallbackTransactionalEnquiryAdmin($v);
935 +
936 + case self::TYPE_ENQUIRY_CUSTOMER_RECEIVED:
937 + return EmailTemplateDefaults::fallbackTransactionalEnquiryReceived($v);
938 +
939 + case self::TYPE_ENQUIRY_CUSTOMER_RESPONSE:
940 + return EmailTemplateDefaults::fallbackTransactionalEnquiryResponse($v);
941 +
942 + case self::TYPE_REVIEW_REQUEST:
943 + return EmailTemplateDefaults::fallbackTransactionalReviewRequest($v);
944 +
945 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
946 + return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoveryFirst($v);
947 +
948 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND:
949 + return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoverySecond($v);
950 +
951 + case self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL:
952 + return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoveryFinal($v);
953 +
324 954 default:
955 + // Pro modules register their own types via
956 + // `yatra_transactional_email_type_to_keys` — they
957 + // supply default body markup through this filter.
958 + // Returning empty string falls back to the generic
959 + // notification block.
960 + $custom = (string) apply_filters(
961 + 'yatra_transactional_email_default_body',
962 + '',
963 + $type,
964 + $v
965 + );
966 + if ($custom !== '') {
967 + return $custom;
968 + }
325 969 return EmailTemplateLayout::customer(
326 970 '✉️',
327 971 __('Notification', 'yatra'),
328 972 '<p style="margin:0;color:#475569;">' . esc_html__('This is an automated message from your travel site.', 'yatra') . '</p>',
@@ -332,8 +976,18 @@
332 976 }
333 977
334 978 /**
335 979 * Build variables from a booking row (admin / cron).
980 + *
981 + * Includes rich tags from {@see BookingEmailRichMergeTags::forBooking()}:
982 + * `payment_gateway`, `payment_gateway_label`, `payment_schedule`, `payment_schedule_label`,
983 + * `travelers_list`, `travelers_list_html`, `traveler_custom_fields_html`, `booking_custom_fields_html`,
984 + * `special_requests`, `special_requests_html`.
985 + *
986 + * Note: `{{payment_method}}` on payment emails is the instrument label (e.g. Card) merged by callers;
987 + * gateway/slug labels use `payment_gateway` / `payment_gateway_label`. Deposit vs full uses `payment_schedule*`.
988 + *
989 + * @return array<string, string> Filter: `yatra_booking_email_variables`.
336 990 */
337 991 public static function variablesFromBooking(object $booking): array
338 992 {
339 993 $currency = $booking->currency ?? SettingsService::getCurrency();
@@ -340,9 +994,10 @@
340 994 $travelDate = !empty($booking->travel_date)
341 995 ? date_i18n(get_option('date_format'), strtotime((string) $booking->travel_date))
342 996 : '';
343 997
344 - return [
998 + $bookingId = (int) ($booking->id ?? 0);
999 + $base = [
345 1000 'customer_name' => trim((string) (($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''))),
346 1001 'customer_first_name' => (string) ($booking->contact_first_name ?? ''),
347 1002 'customer_last_name' => (string) ($booking->contact_last_name ?? ''),
348 1003 'customer_email' => (string) ($booking->contact_email ?? ''),
@@ -347,9 +1002,13 @@
347 1002 'customer_last_name' => (string) ($booking->contact_last_name ?? ''),
348 1003 'customer_email' => (string) ($booking->contact_email ?? ''),
349 1004 'customer_phone' => (string) ($booking->contact_phone ?? ''),
350 1005 'booking_reference' => (string) ($booking->reference ?? ''),
351 - 'booking_id' => (string) (int) ($booking->id ?? 0),
1006 + 'booking_id' => (string) $bookingId,
1007 + 'booking_url' => $bookingId > 0 ? home_url('/my-account/bookings/' . $bookingId) : home_url('/'),
1008 + // Trip context for per-trip template selection (Pro overrides) and
1009 + // for {{trip_id}}; "0" when the booking has no trip.
1010 + 'trip_id' => (string) (int) ($booking->trip_id ?? 0),
352 1011 'trip_name' => (string) ($booking->trip_title ?? ''),
353 1012 'trip_url' => !empty($booking->trip_slug)
354 1013 ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode((string) $booking->trip_slug) . '/')
355 1014 : home_url('/'),
@@ -356,11 +1015,131 @@
356 1015 'travel_date' => $travelDate,
357 1016 'travelers_count' => (string) (int) ($booking->travelers_count ?? 0),
358 1017 'total_amount_formatted' => yatra_format_price((float) ($booking->total_amount ?? 0)),
359 1018 'amount_due_formatted' => yatra_format_price((float) ($booking->amount_due ?? 0)),
1019 + // Aliases for the legacy / customer-customised template
1020 + // syntax: many templates (including ones edited via Settings
1021 + // → Email Templates) reference `{{total_amount}}` and
1022 + // `{{balance_due}}` directly rather than the
1023 + // `_formatted` variants. Without these aliases the
1024 + // placeholders survived unsubstituted into the rendered
1025 + // email body. Aliases use the same formatted-with-currency
1026 + // value as the canonical keys above so templates remain
1027 + // visually consistent regardless of which name is used.
1028 + 'total_amount' => yatra_format_price((float) ($booking->total_amount ?? 0)),
1029 + 'balance_due' => yatra_format_price((float) ($booking->amount_due ?? 0)),
1030 + 'amount_due' => yatra_format_price((float) ($booking->amount_due ?? 0)),
1031 + 'amount_paid' => yatra_format_price((float) ($booking->amount_paid ?? 0)),
1032 + 'amount_paid_formatted' => yatra_format_price((float) ($booking->amount_paid ?? 0)),
360 1033 'currency' => $currency,
361 1034 'booking_status' => (string) ($booking->status ?? ''),
362 1035 'payment_status' => (string) ($booking->payment_status ?? ''),
363 1036 'admin_url' => admin_url('admin.php?page=yatra'),
364 1037 ];
1038 +
1039 + $rich = BookingEmailRichMergeTags::forBooking($booking);
1040 +
1041 + /** @var array<string, string> $merged */
1042 + $merged = array_merge($base, $rich);
1043 +
1044 + return apply_filters('yatra_booking_email_variables', $merged, $booking);
1045 + }
1046 +
1047 + /**
1048 + * Merge tags for enquiry emails (row from EnquiryRepository::findWithTrip()).
1049 + *
1050 + * @param object $enquiry Row with name, email, phone, message, trip_title, etc.
1051 + * @return array<string, string>
1052 + */
1053 + public static function variablesFromEnquiry(object $enquiry, string $responsePlain = ''): array
1054 + {
1055 + $trip = trim((string) ($enquiry->trip_title ?? ''));
1056 + $tripSlug = (string) ($enquiry->trip_slug ?? '');
1057 + $tripId = isset($enquiry->trip_id) ? (int) $enquiry->trip_id : 0;
1058 +
1059 + // Defense-in-depth: if repository join didn't provide trip_title/slug but we do have a trip_id,
1060 + // resolve the trip directly so {{trip_name}} doesn't fall back to "General enquiry".
1061 + if (($trip === '' || $tripSlug === '') && $tripId > 0) {
1062 + try {
1063 + $repo = new \Yatra\Repositories\TripRepository();
1064 + $tripRow = $repo->find($tripId);
1065 + if ($tripRow) {
1066 + if ($trip === '' && !empty($tripRow->title)) {
1067 + $trip = trim((string) $tripRow->title);
1068 + }
1069 + if ($tripSlug === '' && !empty($tripRow->slug)) {
1070 + $tripSlug = (string) $tripRow->slug;
1071 + }
1072 + }
1073 + } catch (\Throwable $e) {
1074 + // Ignore: keep existing values/fallback.
1075 + }
1076 + }
1077 +
1078 + $tripUrl = $tripSlug !== ''
1079 + ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode($tripSlug) . '/')
1080 + : home_url('/');
1081 +
1082 + $created = (string) ($enquiry->created_at ?? '');
1083 + $enquiryDate = $created !== ''
1084 + ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($created) ?: time())
1085 + : '';
1086 +
1087 + $vars = [
1088 + 'customer_name' => (string) ($enquiry->name ?? ''),
1089 + 'customer_email' => (string) ($enquiry->email ?? ''),
1090 + 'customer_phone' => (string) ($enquiry->phone ?? ''),
1091 + 'enquiry_id' => (string) (int) ($enquiry->id ?? 0),
1092 + 'enquiry_date' => $enquiryDate,
1093 + 'subject' => (string) ($enquiry->subject ?? ''),
1094 + 'trip_name' => $trip !== '' ? $trip : __('General enquiry', 'yatra'),
1095 + 'trip_url' => $tripUrl,
1096 + 'message' => nl2br(esc_html((string) ($enquiry->message ?? ''))),
1097 + 'original_message' => (string) ($enquiry->message ?? ''),
1098 + ];
1099 +
1100 + // Response-only tags are injected solely on the response email so the
1101 + // sidebar for `enquiry.created` doesn't surface tags that would render
1102 + // empty in that context.
1103 + if ($responsePlain !== '') {
1104 + $responseHtml = nl2br(esc_html($responsePlain));
1105 + $vars['response'] = $responseHtml;
1106 + $vars['response_message'] = $responseHtml;
1107 + $vars['response_date'] = date_i18n(get_option('date_format') . ' ' . get_option('time_format'));
1108 + }
1109 +
1110 + return $vars;
1111 + }
1112 +
1113 + /**
1114 + * @param object $booking Booking row (contact_*, reference, …)
1115 + * @param array<string, string> $extra e.g. expiry_hours, expiry_notice_html
1116 + * @return array<string, string>
1117 + */
1118 + public static function variablesFromBookingWithExtras(object $booking, array $extra = []): array
1119 + {
1120 + return array_merge(self::variablesFromBooking($booking), $extra);
1121 + }
1122 +
1123 + /**
1124 + * @param object $booking Booking row
1125 + * @param object $scheduledRow Scheduled payment row (amount, currency, scheduled_date, payment_type, …)
1126 + * @param array<string, string> $extra failure_reason, balance_after_formatted, permanent_failure, …
1127 + * @return array<string, string>
1128 + */
1129 + public static function variablesFromScheduledPayment(object $booking, object $scheduledRow, array $extra = []): array
1130 + {
1131 + $currency = (string) ($scheduledRow->currency ?? $booking->currency ?? SettingsService::getCurrency());
1132 + $amount = (float) ($scheduledRow->amount ?? 0);
1133 + $schedDate = !empty($scheduledRow->scheduled_date)
1134 + ? date_i18n(get_option('date_format'), strtotime((string) $scheduledRow->scheduled_date))
1135 + : '';
1136 +
1137 + $base = self::variablesFromBooking($booking);
1138 + $base['scheduled_amount_formatted'] = yatra_format_price($amount, $currency);
1139 + $base['scheduled_date_formatted'] = $schedDate;
1140 + $base['payment_type_label'] = (string) ($scheduledRow->payment_type ?? '');
1141 + $base['scheduled_payment_id'] = (string) (int) ($scheduledRow->id ?? 0);
1142 +
1143 + return array_merge($base, $extra);
365 1144 }
366 1145 }