PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
yatra / app / Services / TransactionalEmailTemplateService.php

TransactionalEmailTemplateService.php in Yatra – Travel Booking & Tour Operator Software 3.0.17, at app/Services/TransactionalEmailTemplateService.php

1,169 lines 51.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 /**
8 * Central transactional emails (booking, payment, cancellation, reminder).
9 * Content is editable under Email → Templates (settings-backed) or overridden by Pro Email Automation DB templates via filter.
10 */
11 class TransactionalEmailTemplateService
12 {
13 public const TYPE_BOOKING_CONFIRMATION = 'booking_confirmation';
14
15 public const TYPE_PAYMENT_CONFIRMATION = 'payment_confirmation';
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
26 public const TYPE_BOOKING_CANCELLATION = 'booking_cancellation';
27
28 public const TYPE_BOOKING_REMINDER = 'booking_reminder';
29
30 public const TYPE_ADMIN_NEW_BOOKING = 'admin_new_booking';
31
32 public const TYPE_ADMIN_PAYMENT_RECEIVED = 'admin_payment_received';
33
34 public const TYPE_ADMIN_BOOKING_CANCELLED = 'admin_booking_cancelled_notice';
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
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 /**
87 * Map catalog / settings UI keys to internal render types.
88 */
89 public static function coreTemplateKeyToType(string $templateKey): ?string
90 {
91 $map = [
92 'booking_confirmation' => self::TYPE_BOOKING_CONFIRMATION,
93 'payment_received' => self::TYPE_PAYMENT_CONFIRMATION,
94 'partial_payment_received' => self::TYPE_PARTIAL_PAYMENT_RECEIVED,
95 'booking_cancelled' => self::TYPE_BOOKING_CANCELLATION,
96 'trip_reminder' => self::TYPE_BOOKING_REMINDER,
97 'admin_new_booking' => self::TYPE_ADMIN_NEW_BOOKING,
98 'admin_payment_received' => self::TYPE_ADMIN_PAYMENT_RECEIVED,
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,
119 ];
120
121 return $map[$templateKey] ?? null;
122 }
123
124 /**
125 * Sample merge-tag values for admin preview (core templates).
126 *
127 * @return array<string, string>
128 */
129 public static function sampleVariablesForCoreTemplateKey(string $templateKey): array
130 {
131 return EmailTemplateSampleData::forTemplateKey($templateKey);
132 }
133
134 /**
135 * @param array<string, string|int|float> $variables
136 * @return array<string, string>
137 */
138 public static function mergeTemplateVariables(array $variables): array
139 {
140 return self::mergeDefaultVariables($variables);
141 }
142
143 /**
144 * Replace {{word}} placeholders (used by previews and extensions).
145 *
146 * @param array<string, string|int|float> $variables
147 */
148 public static function parseMergeTags(string $template, array $variables): string
149 {
150 $variables = self::mergeDefaultVariables($variables);
151
152 return self::parseTemplate($template, $variables);
153 }
154
155 /**
156 * Render using explicit subject/body templates (e.g. unsaved editor content). Empty strings use built-in defaults.
157 *
158 * @param array<string, string|int|float> $variables
159 * @return array{subject: string, body: string}
160 */
161 public static function renderWithStringTemplates(string $type, string $subjectTpl, string $bodyTpl, array $variables): array
162 {
163 $variables = self::mergeDefaultVariables($variables);
164 $variables = self::normalizeVariablesForType($type, $variables);
165 $map = self::typeToSettingsKeys();
166 if (!isset($map[$type])) {
167 return ['subject' => '', 'body' => ''];
168 }
169
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.
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 }
184 } else {
185 $subject = self::parseTemplate($subjectTpl, $variables);
186 }
187
188 if ($bodyTpl === '') {
189 $body = self::defaultBody($type, $variables);
190 } else {
191 $body = self::parseTemplate($bodyTpl, $variables);
192 }
193
194 return [
195 'subject' => $subject,
196 'body' => $body,
197 ];
198 }
199
200 /**
201 * @return array<string, string>
202 */
203 private static function typeToSettingsKeys(): array
204 {
205 $defaults = [
206 self::TYPE_BOOKING_CONFIRMATION => [
207 'flag' => 'email_template_booking',
208 'subject' => 'email_tpl_booking_subject',
209 'body' => 'email_tpl_booking_body',
210 ],
211 self::TYPE_PAYMENT_CONFIRMATION => [
212 'flag' => 'email_template_confirmation',
213 'subject' => 'email_tpl_payment_subject',
214 'body' => 'email_tpl_payment_body',
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 ],
221 self::TYPE_BOOKING_CANCELLATION => [
222 'flag' => 'email_template_cancellation',
223 'subject' => 'email_tpl_cancellation_subject',
224 'body' => 'email_tpl_cancellation_body',
225 ],
226 self::TYPE_BOOKING_REMINDER => [
227 'flag' => 'email_template_reminder',
228 'subject' => 'email_tpl_reminder_subject',
229 'body' => 'email_tpl_reminder_body',
230 ],
231 self::TYPE_ADMIN_NEW_BOOKING => [
232 'flag' => 'email_template_admin_new_booking',
233 'subject' => 'email_tpl_admin_booking_subject',
234 'body' => 'email_tpl_admin_booking_body',
235 ],
236 self::TYPE_ADMIN_PAYMENT_RECEIVED => [
237 'flag' => 'email_template_admin_payment',
238 'subject' => 'email_tpl_admin_payment_subject',
239 'body' => 'email_tpl_admin_payment_body',
240 ],
241 self::TYPE_ADMIN_BOOKING_CANCELLED => [
242 'flag' => 'email_template_admin_cancellation',
243 'subject' => 'email_tpl_admin_cancellation_subject',
244 'body' => 'email_tpl_admin_cancellation_body',
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 ],
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);
379 }
380
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 /**
471 * Send if the type is enabled in settings. Pro may handle via {@see 'yatra_send_transactional_email'}.
472 *
473 * Optional string `transactional_context` (e.g. `booking_created`, `status_confirmed`) is passed through
474 * to the filter so Pro can choose a different template row for the same TYPE_BOOKING_CONFIRMATION.
475 *
476 * @param array<string, string|int|float> $variables Merge tags: {{key}}
477 */
478 public static function sendIfEnabled(string $type, string $to, array $variables = []): bool
479 {
480 $to = sanitize_email($to);
481 if ($to === '' || !is_email($to)) {
482 return false;
483 }
484
485 $map = self::typeToSettingsKeys();
486 if (!isset($map[$type])) {
487 return false;
488 }
489
490 $flag = $map[$type]['flag'];
491 $proOwnsType = (bool) apply_filters('yatra_pro_email_automation_owns_transactional_type', false, $type);
492
493 if (!$proOwnsType && !SettingsService::isEnabled($flag)) {
494 return false;
495 }
496
497 $variables = self::mergeDefaultVariables($variables);
498 $variables = self::normalizeVariablesForType($type, $variables);
499
500 /**
501 * Allow Yatra Pro (or extensions) to send instead of core templates.
502 * Return null to use core; true/false if handled.
503 */
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;
509
510 try {
511 $handled = apply_filters('yatra_send_transactional_email', null, $type, $to, $variables);
512 if ($handled !== null) {
513 return (bool) $handled;
514 }
515
516 if (!SettingsService::isEnabled($flag)) {
517 return false;
518 }
519
520 $rendered = self::render($type, $variables);
521
522 $sent = EmailService::send(
523 $to,
524 $rendered['subject'],
525 $rendered['body'],
526 ['Content-Type: text/html; charset=UTF-8']
527 );
528
529 /**
530 * A transactional email core just sent.
531 *
532 * Yatra Pro writes the Email Logs, but only from its own modules —
533 * nothing records what core sends, so a booking confirmation or a
534 * review request left no trace and an operator looking for "which
535 * email went to this customer" found a log that quietly covered
536 * only part of the picture. Pro listens for this and logs it.
537 *
538 * @param string $type Template type, e.g. review_request.
539 * @param string $to Recipient address.
540 * @param string $subject Rendered subject.
541 * @param string $body Rendered body.
542 * @param bool $sent Whether wp_mail() accepted it.
543 * @param array<string, mixed> $variables Merge variables used to render.
544 */
545 do_action('yatra_transactional_email_sent', $type, $to, $rendered['subject'], $rendered['body'], $sent, $variables);
546
547 return $sent;
548 } finally {
549 self::$dispatchingType = $previousType;
550 }
551 }
552
553 /**
554 * @param array<string, string|int|float> $variables
555 * @return array{subject: string, body: string}
556 */
557 public static function render(string $type, array $variables): array
558 {
559 $map = self::typeToSettingsKeys();
560 if (!isset($map[$type])) {
561 return ['subject' => '', 'body' => ''];
562 }
563
564 $subjectKey = $map[$type]['subject'];
565 $bodyKey = $map[$type]['body'];
566
567 $subjectTpl = SettingsService::getString($subjectKey, '');
568 $bodyTpl = SettingsService::getString($bodyKey, '');
569
570 return self::renderWithStringTemplates($type, $subjectTpl, $bodyTpl, $variables);
571 }
572
573 /**
574 * Would the template that actually gets sent for $type render the
575 * verification link ({{verification_link}})? Guest checkout can't complete
576 * without it, so the checkout controller uses this to decide whether an
577 * operator's customised verification template is safe to use, or whether to
578 * fall back to the built-in default. Respects Pro ownership: a Pro DB
579 * template reports its raw body via `yatra_transactional_email_effective_body`;
580 * otherwise the core option body is checked, and an empty option means the
581 * built-in default (which always includes the link) is used.
582 */
583 public static function templateRendersVerificationLink(string $type): bool
584 {
585 $effective = apply_filters('yatra_transactional_email_effective_body', null, $type);
586 if (is_string($effective) && $effective !== '') {
587 return strpos($effective, 'verification_link') !== false;
588 }
589
590 $map = self::typeToSettingsKeys();
591 if (!isset($map[$type])) {
592 return false;
593 }
594
595 $body = SettingsService::getString($map[$type]['body'], '');
596 if (trim($body) === '') {
597 return true; // no custom body → built-in default is used, which always carries the link
598 }
599
600 return strpos($body, 'verification_link') !== false;
601 }
602
603 /**
604 * @param array<string, string|int|float> $variables
605 * @return array<string, string>
606 */
607 private static function mergeDefaultVariables(array $variables): array
608 {
609 $defaults = [
610 'site_name' => get_bloginfo('name'),
611 'site_url' => home_url('/'),
612 'admin_email' => SettingsService::getString('admin_email', get_option('admin_email')),
613 ];
614
615 $out = [];
616 foreach (array_merge($defaults, $variables) as $k => $v) {
617 $out[(string) $k] = is_scalar($v) ? (string) $v : '';
618 }
619
620 return $out;
621 }
622
623 /**
624 * Ensure templates always have safe, meaningful defaults for commonly-used tags.
625 *
626 * This prevents "blank sections" when a caller supplies only the core booking variables
627 * (e.g. status-change emails) while the template contains richer optional sections.
628 *
629 * @param array<string, string> $variables
630 * @return array<string, string>
631 */
632 private static function normalizeVariablesForType(string $type, array $variables): array
633 {
634 // Booking confirmation is sent from multiple contexts (checkout + admin status changes).
635 // If the caller didn't include the rich "intro/details/footer" blocks, provide a minimal,
636 // data-driven fallback so the email still looks correct.
637 if ($type === self::TYPE_BOOKING_CONFIRMATION) {
638 if (!isset($variables['intro_paragraph']) || trim($variables['intro_paragraph']) === '') {
639 $variables['intro_paragraph'] = __('Thank you for your booking.', 'yatra');
640 }
641 if (!isset($variables['details_html']) || trim($variables['details_html']) === '') {
642 $variables['details_html'] = self::fallbackBookingDetailsHtml($variables);
643 }
644 if (!isset($variables['footer_note']) || trim($variables['footer_note']) === '') {
645 /* translators: %s: site name. */
646 $variables['footer_note'] = sprintf(__('— %s', 'yatra'), get_bloginfo('name'));
647 }
648 }
649
650 // Shared defaults that are safe for most templates if included.
651 if (!isset($variables['intro_paragraph'])) {
652 $variables['intro_paragraph'] = '';
653 }
654 if (!isset($variables['footer_note'])) {
655 $variables['footer_note'] = '';
656 }
657 if (!isset($variables['details_html'])) {
658 $variables['details_html'] = '';
659 }
660
661 return $variables;
662 }
663
664 /**
665 * Minimal booking details block for confirmation emails when caller doesn't provide `details_html`.
666 *
667 * @param array<string, string> $v
668 */
669 private static function fallbackBookingDetailsHtml(array $v): string
670 {
671 $trip = $v['trip_name'] ?? '';
672 $date = $v['travel_date'] ?? '';
673 $pax = $v['travelers_count'] ?? '';
674 $total = $v['total_amount_formatted'] ?? '';
675 $due = $v['amount_due_formatted'] ?? '';
676
677 $rows = [];
678 if ($trip !== '') {
679 $rows[] = ['label' => __('Trip', 'yatra'), 'value' => esc_html($trip)];
680 }
681 if ($date !== '') {
682 $rows[] = ['label' => __('Departure', 'yatra'), 'value' => esc_html($date)];
683 }
684 if ($pax !== '') {
685 $rows[] = ['label' => __('Travelers', 'yatra'), 'value' => esc_html($pax)];
686 }
687 if ($total !== '') {
688 $rows[] = ['label' => __('Total', 'yatra'), 'value' => esc_html($total)];
689 }
690 if ($due !== '') {
691 $rows[] = ['label' => __('Amount due', 'yatra'), 'value' => esc_html($due)];
692 }
693
694 if (empty($rows)) {
695 return '';
696 }
697
698 return EmailTemplateLayout::detailCard($rows);
699 }
700
701 /**
702 * @param array<string, string> $variables
703 */
704 private static function parseTemplate(string $template, array $variables): string
705 {
706 $rendered = (string) preg_replace_callback(
707 // Allow optional whitespace: {{trip_name}} and {{ trip_name }} both work.
708 '/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/',
709 static function (array $m) use ($variables): string {
710 $key = $m[1];
711
712 // Never leak raw merge-tags into real emails. If a variable is
713 // missing, replace it with an empty string rather than
714 // returning the original {{tag}} token.
715 return $variables[$key] ?? '';
716 },
717 $template
718 );
719
720 // Hard-strip any remaining merge-tags (defense-in-depth).
721 $rendered = (string) preg_replace('/\{\{\s*[a-zA-Z0-9_]+\s*\}\}/', '', $rendered);
722
723 // Users sometimes paste helper text from the editor into the template.
724 // If that happens, strip common helper headings so they don't appear in
725 // production emails.
726 $rendered = (string) preg_replace(
727 '/^.*(Available Variables|Available placeholders|Available Placeholders|Merge tags).*$/mi',
728 '',
729 $rendered
730 );
731
732 return $rendered;
733 }
734
735 /**
736 * @param array<string, string> $v
737 */
738 private static function defaultSubject(string $type, array $v): string
739 {
740 $site = $v['site_name'] ?? get_bloginfo('name');
741 $ref = $v['booking_reference'] ?? $v['booking_id'] ?? '';
742
743 switch ($type) {
744 case self::TYPE_BOOKING_CONFIRMATION:
745 /* translators: 1: site name, 2: booking reference. */
746 return sprintf(__('✈️ [%1$s] Booking update · %2$s', 'yatra'), $site, $ref);
747
748 case self::TYPE_PAYMENT_CONFIRMATION:
749 /* translators: 1: site name, 2: booking reference. */
750 return sprintf(__('�
751 [%1$s] Payment received · %2$s', 'yatra'), $site, $ref);
752
753 case self::TYPE_PARTIAL_PAYMENT_RECEIVED:
754 /* translators: 1: site name, 2: booking reference. */
755 return sprintf(__('💳 [%1$s] Part payment received · %2$s', 'yatra'), $site, $ref);
756
757 case self::TYPE_BOOKING_CANCELLATION:
758 /* translators: 1: site name, 2: booking reference. */
759 return sprintf(__('📋 [%1$s] Booking cancelled · %2$s', 'yatra'), $site, $ref);
760
761 case self::TYPE_BOOKING_REMINDER:
762 /* translators: 1: site name, 2: booking reference. */
763 return sprintf(__('🗓️ [%1$s] Your trip is coming up · %2$s', 'yatra'), $site, $ref);
764
765 case self::TYPE_ADMIN_NEW_BOOKING:
766 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
767 return sprintf(__('🔔 [%1$s] New booking · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
768
769 case self::TYPE_ADMIN_PAYMENT_RECEIVED:
770 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
771 return sprintf(__('�
772 [%1$s] Payment received · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
773
774 case self::TYPE_ADMIN_BOOKING_CANCELLED:
775 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
776 return sprintf(__('📋 [%1$s] Booking cancelled · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
777
778 case self::TYPE_TRIP_CONSENT_REQUEST:
779 $formName = $v['form_name'] ?? __('consent form', 'yatra');
780
781 /* translators: 1: site name, 2: consent form name. */
782 return sprintf(__('📝 [%1$s] Action required · %2$s', 'yatra'), $site, $formName);
783
784 case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
785 /* translators: %s: site name. */
786 return sprintf(__('✉️ [%s] Verify your email address', 'yatra'), $site);
787
788 case self::TYPE_GUEST_EMAIL_VERIFICATION:
789 // Distinct subject so customers can tell apart "verify
790 // your account" from "verify to complete your booking".
791 /* translators: %s: site name. */
792 return sprintf(__('✉️ [%s] Verify your email to complete your booking', 'yatra'), $site);
793
794 case self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST:
795 /* translators: %s: site name. */
796 return sprintf(__('✉️ [%s] Confirm your new email address', 'yatra'), $site);
797
798 case self::TYPE_ACCOUNT_EMAIL_CHANGED:
799 /* translators: %s: site name. */
800 return sprintf(__('🔔 [%s] Your email address was changed', 'yatra'), $site);
801
802 case self::TYPE_BOOKING_COMPLETED:
803 /* translators: 1: site name, 2: booking reference. */
804 return sprintf(__('🌟 [%1$s] Trip complete · %2$s', 'yatra'), $site, $ref);
805
806 case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
807 /* translators: 1: site name, 2: booking reference. */
808 return sprintf(__('⏱️ [%1$s] Booking expired · %2$s', 'yatra'), $site, $ref);
809
810 case self::TYPE_ADMIN_BOOKING_EXPIRED:
811 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
812 return sprintf(__('⏱️ [%1$s] Booking expired · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
813
814 case self::TYPE_SCHEDULED_PAYMENT_REMINDER:
815 /* translators: 1: site name, 2: booking reference. */
816 return sprintf(__('💳 [%1$s] Upcoming payment · %2$s', 'yatra'), $site, $ref);
817
818 case self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED:
819 /* translators: 1: site name, 2: booking reference. */
820 return sprintf(__('�
821 [%1$s] Scheduled payment received · %2$s', 'yatra'), $site, $ref);
822
823 case self::TYPE_SCHEDULED_PAYMENT_FAILED:
824 /* translators: 1: site name, 2: booking reference. */
825 return sprintf(__('⚠️ [%1$s] Payment issue · %2$s', 'yatra'), $site, $ref);
826
827 case self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED:
828 /* translators: 1: site name, 2: booking reference. */
829 return sprintf(__('⚠️ [%1$s] Scheduled payment failed · %2$s', 'yatra'), $site, $ref);
830
831 case self::TYPE_ENQUIRY_ADMIN:
832 $who = $v['customer_name'] ?? __('Customer', 'yatra');
833
834 /* translators: 1: site name, 2: customer name. */
835 return sprintf(__('💬 [%1$s] New enquiry · %2$s', 'yatra'), $site, $who);
836
837 case self::TYPE_ENQUIRY_CUSTOMER_RECEIVED:
838 /* translators: %s: site name. */
839 return sprintf(__('✉️ [%s] We received your message', 'yatra'), $site);
840
841 case self::TYPE_ENQUIRY_CUSTOMER_RESPONSE:
842 /* translators: %s: site name. */
843 return sprintf(__('💬 [%s] Re: your enquiry', 'yatra'), $site);
844
845 case self::TYPE_REVIEW_REQUEST:
846 $trip = $v['trip_name'] ?? __('your trip', 'yatra');
847
848 /* translators: 1: site name, 2: trip name. */
849 return sprintf(__('⭐ [%1$s] How was %2$s?', 'yatra'), $site, $trip);
850
851 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
852 /* translators: %s: site name. */
853 return sprintf(__('🛒 [%s] Complete your booking', 'yatra'), $site);
854
855 case self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND:
856 /* translators: %s: site name. */
857 return sprintf(__('⏳ [%s] Still interested? Your booking is waiting', 'yatra'), $site);
858
859 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL:
860 /* translators: %s: site name. */
861 return sprintf(__('⚠️ [%s] Final reminder: complete your booking', 'yatra'), $site);
862
863 default:
864 // Pro modules register their own types via
865 // `yatra_transactional_email_type_to_keys` — they
866 // supply default copy through this filter. Returning
867 // empty string means "no extension claimed this type"
868 // and we fall back to the generic notification line.
869 $custom = (string) apply_filters(
870 'yatra_transactional_email_default_subject',
871 '',
872 $type,
873 $v
874 );
875 if ($custom !== '') {
876 return $custom;
877 }
878 /* translators: %s: site name. */
879 return sprintf(__('✉️ [%s] Notification', 'yatra'), $site);
880 }
881 }
882
883 /**
884 * @param array<string, string> $v
885 */
886 private static function defaultBody(string $type, array $v): string
887 {
888 switch ($type) {
889 case self::TYPE_BOOKING_CONFIRMATION:
890 return EmailTemplateDefaults::fallbackTransactionalBooking($v);
891
892 case self::TYPE_PAYMENT_CONFIRMATION:
893 return EmailTemplateDefaults::fallbackTransactionalPayment($v);
894
895 case self::TYPE_PARTIAL_PAYMENT_RECEIVED:
896 return EmailTemplateDefaults::fallbackTransactionalPartialPayment($v);
897
898 case self::TYPE_BOOKING_CANCELLATION:
899 return EmailTemplateDefaults::fallbackTransactionalCancellation($v);
900
901 case self::TYPE_BOOKING_REMINDER:
902 return EmailTemplateDefaults::fallbackTransactionalReminder($v);
903
904 case self::TYPE_ADMIN_NEW_BOOKING:
905 return EmailTemplateDefaults::fallbackAdminNewBooking($v);
906
907 case self::TYPE_ADMIN_PAYMENT_RECEIVED:
908 return EmailTemplateDefaults::fallbackAdminPaymentReceived($v);
909
910 case self::TYPE_ADMIN_BOOKING_CANCELLED:
911 return EmailTemplateDefaults::fallbackAdminBookingCancelled($v);
912
913 case self::TYPE_TRIP_CONSENT_REQUEST:
914 return EmailTemplateDefaults::fallbackTransactionalTripConsent($v);
915
916 case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
917 return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v);
918
919 case self::TYPE_GUEST_EMAIL_VERIFICATION:
920 // Reuse the customer-verification body. The flow is
921 // similar — click a magic link to prove ownership of
922 // the address — and operators that have already
923 // customised the customer-verification copy get a
924 // consistent look across both. Differentiating copy is
925 // injected at call-time via the intro_paragraph /
926 // footer_note merge tags by the booking handler.
927 return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v);
928
929 case self::TYPE_ACCOUNT_EMAIL_CHANGE_REQUEST:
930 return EmailTemplateDefaults::fallbackTransactionalAccountEmailChangeRequest($v);
931
932 case self::TYPE_ACCOUNT_EMAIL_CHANGED:
933 return EmailTemplateDefaults::fallbackTransactionalAccountEmailChanged($v);
934
935 case self::TYPE_BOOKING_COMPLETED:
936 return EmailTemplateDefaults::fallbackTransactionalBookingCompleted($v);
937
938 case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
939 return EmailTemplateDefaults::fallbackTransactionalBookingExpiredCustomer($v);
940
941 case self::TYPE_ADMIN_BOOKING_EXPIRED:
942 return EmailTemplateDefaults::fallbackAdminBookingExpired($v);
943
944 case self::TYPE_SCHEDULED_PAYMENT_REMINDER:
945 return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentReminder($v);
946
947 case self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED:
948 return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentSucceeded($v);
949
950 case self::TYPE_SCHEDULED_PAYMENT_FAILED:
951 return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentFailed($v);
952
953 case self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED:
954 return EmailTemplateDefaults::fallbackAdminScheduledPaymentFailed($v);
955
956 case self::TYPE_ENQUIRY_ADMIN:
957 return EmailTemplateDefaults::fallbackTransactionalEnquiryAdmin($v);
958
959 case self::TYPE_ENQUIRY_CUSTOMER_RECEIVED:
960 return EmailTemplateDefaults::fallbackTransactionalEnquiryReceived($v);
961
962 case self::TYPE_ENQUIRY_CUSTOMER_RESPONSE:
963 return EmailTemplateDefaults::fallbackTransactionalEnquiryResponse($v);
964
965 case self::TYPE_REVIEW_REQUEST:
966 return EmailTemplateDefaults::fallbackTransactionalReviewRequest($v);
967
968 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
969 return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoveryFirst($v);
970
971 case self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND:
972 return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoverySecond($v);
973
974 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL:
975 return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoveryFinal($v);
976
977 default:
978 // Pro modules register their own types via
979 // `yatra_transactional_email_type_to_keys` — they
980 // supply default body markup through this filter.
981 // Returning empty string falls back to the generic
982 // notification block.
983 $custom = (string) apply_filters(
984 'yatra_transactional_email_default_body',
985 '',
986 $type,
987 $v
988 );
989 if ($custom !== '') {
990 return $custom;
991 }
992 return EmailTemplateLayout::customer(
993 '✉️',
994 __('Notification', 'yatra'),
995 '<p style="margin:0;color:#475569;">' . esc_html__('This is an automated message from your travel site.', 'yatra') . '</p>',
996 esc_html($v['site_name'] ?? get_bloginfo('name'))
997 );
998 }
999 }
1000
1001 /**
1002 * Build variables from a booking row (admin / cron).
1003 *
1004 * Includes rich tags from {@see BookingEmailRichMergeTags::forBooking()}:
1005 * `payment_gateway`, `payment_gateway_label`, `payment_schedule`, `payment_schedule_label`,
1006 * `travelers_list`, `travelers_list_html`, `traveler_custom_fields_html`, `booking_custom_fields_html`,
1007 * `special_requests`, `special_requests_html`.
1008 *
1009 * Note: `{{payment_method}}` on payment emails is the instrument label (e.g. Card) merged by callers;
1010 * gateway/slug labels use `payment_gateway` / `payment_gateway_label`. Deposit vs full uses `payment_schedule*`.
1011 *
1012 * @return array<string, string> Filter: `yatra_booking_email_variables`.
1013 */
1014 public static function variablesFromBooking(object $booking): array
1015 {
1016 $currency = $booking->currency ?? SettingsService::getCurrency();
1017 $travelDate = !empty($booking->travel_date)
1018 ? date_i18n(get_option('date_format'), strtotime((string) $booking->travel_date))
1019 : '';
1020
1021 $bookingId = (int) ($booking->id ?? 0);
1022 $base = [
1023 'customer_name' => trim((string) (($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''))),
1024 'customer_first_name' => (string) ($booking->contact_first_name ?? ''),
1025 'customer_last_name' => (string) ($booking->contact_last_name ?? ''),
1026 'customer_email' => (string) ($booking->contact_email ?? ''),
1027 'customer_phone' => (string) ($booking->contact_phone ?? ''),
1028 'booking_reference' => (string) ($booking->reference ?? ''),
1029 'booking_id' => (string) $bookingId,
1030 'booking_url' => $bookingId > 0 ? home_url('/my-account/bookings/' . $bookingId) : home_url('/'),
1031 // Trip context for per-trip template selection (Pro overrides) and
1032 // for {{trip_id}}; "0" when the booking has no trip.
1033 'trip_id' => (string) (int) ($booking->trip_id ?? 0),
1034 'trip_name' => (string) ($booking->trip_title ?? ''),
1035 'trip_url' => !empty($booking->trip_slug)
1036 ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode((string) $booking->trip_slug) . '/')
1037 : home_url('/'),
1038 'travel_date' => $travelDate,
1039 'travelers_count' => (string) (int) ($booking->travelers_count ?? 0),
1040 'total_amount_formatted' => yatra_format_price((float) ($booking->total_amount ?? 0)),
1041 'amount_due_formatted' => yatra_format_price((float) ($booking->amount_due ?? 0)),
1042 // Aliases for the legacy / customer-customised template
1043 // syntax: many templates (including ones edited via Settings
1044 // → Email Templates) reference `{{total_amount}}` and
1045 // `{{balance_due}}` directly rather than the
1046 // `_formatted` variants. Without these aliases the
1047 // placeholders survived unsubstituted into the rendered
1048 // email body. Aliases use the same formatted-with-currency
1049 // value as the canonical keys above so templates remain
1050 // visually consistent regardless of which name is used.
1051 'total_amount' => yatra_format_price((float) ($booking->total_amount ?? 0)),
1052 'balance_due' => yatra_format_price((float) ($booking->amount_due ?? 0)),
1053 'amount_due' => yatra_format_price((float) ($booking->amount_due ?? 0)),
1054 'amount_paid' => yatra_format_price((float) ($booking->amount_paid ?? 0)),
1055 'amount_paid_formatted' => yatra_format_price((float) ($booking->amount_paid ?? 0)),
1056 'currency' => $currency,
1057 'booking_status' => (string) ($booking->status ?? ''),
1058 'payment_status' => (string) ($booking->payment_status ?? ''),
1059 'admin_url' => admin_url('admin.php?page=yatra'),
1060 ];
1061
1062 $rich = BookingEmailRichMergeTags::forBooking($booking);
1063
1064 /** @var array<string, string> $merged */
1065 $merged = array_merge($base, $rich);
1066
1067 return apply_filters('yatra_booking_email_variables', $merged, $booking);
1068 }
1069
1070 /**
1071 * Merge tags for enquiry emails (row from EnquiryRepository::findWithTrip()).
1072 *
1073 * @param object $enquiry Row with name, email, phone, message, trip_title, etc.
1074 * @return array<string, string>
1075 */
1076 public static function variablesFromEnquiry(object $enquiry, string $responsePlain = ''): array
1077 {
1078 $trip = trim((string) ($enquiry->trip_title ?? ''));
1079 $tripSlug = (string) ($enquiry->trip_slug ?? '');
1080 $tripId = isset($enquiry->trip_id) ? (int) $enquiry->trip_id : 0;
1081
1082 // Defense-in-depth: if repository join didn't provide trip_title/slug but we do have a trip_id,
1083 // resolve the trip directly so {{trip_name}} doesn't fall back to "General enquiry".
1084 if (($trip === '' || $tripSlug === '') && $tripId > 0) {
1085 try {
1086 $repo = new \Yatra\Repositories\TripRepository();
1087 $tripRow = $repo->find($tripId);
1088 if ($tripRow) {
1089 if ($trip === '' && !empty($tripRow->title)) {
1090 $trip = trim((string) $tripRow->title);
1091 }
1092 if ($tripSlug === '' && !empty($tripRow->slug)) {
1093 $tripSlug = (string) $tripRow->slug;
1094 }
1095 }
1096 } catch (\Throwable $e) {
1097 // Ignore: keep existing values/fallback.
1098 }
1099 }
1100
1101 $tripUrl = $tripSlug !== ''
1102 ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode($tripSlug) . '/')
1103 : home_url('/');
1104
1105 $created = (string) ($enquiry->created_at ?? '');
1106 $enquiryDate = $created !== ''
1107 ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($created) ?: time())
1108 : '';
1109
1110 $vars = [
1111 'customer_name' => (string) ($enquiry->name ?? ''),
1112 'customer_email' => (string) ($enquiry->email ?? ''),
1113 'customer_phone' => (string) ($enquiry->phone ?? ''),
1114 'enquiry_id' => (string) (int) ($enquiry->id ?? 0),
1115 'enquiry_date' => $enquiryDate,
1116 'subject' => (string) ($enquiry->subject ?? ''),
1117 'trip_name' => $trip !== '' ? $trip : __('General enquiry', 'yatra'),
1118 'trip_url' => $tripUrl,
1119 'message' => nl2br(esc_html((string) ($enquiry->message ?? ''))),
1120 'original_message' => (string) ($enquiry->message ?? ''),
1121 ];
1122
1123 // Response-only tags are injected solely on the response email so the
1124 // sidebar for `enquiry.created` doesn't surface tags that would render
1125 // empty in that context.
1126 if ($responsePlain !== '') {
1127 $responseHtml = nl2br(esc_html($responsePlain));
1128 $vars['response'] = $responseHtml;
1129 $vars['response_message'] = $responseHtml;
1130 $vars['response_date'] = date_i18n(get_option('date_format') . ' ' . get_option('time_format'));
1131 }
1132
1133 return $vars;
1134 }
1135
1136 /**
1137 * @param object $booking Booking row (contact_*, reference, …)
1138 * @param array<string, string> $extra e.g. expiry_hours, expiry_notice_html
1139 * @return array<string, string>
1140 */
1141 public static function variablesFromBookingWithExtras(object $booking, array $extra = []): array
1142 {
1143 return array_merge(self::variablesFromBooking($booking), $extra);
1144 }
1145
1146 /**
1147 * @param object $booking Booking row
1148 * @param object $scheduledRow Scheduled payment row (amount, currency, scheduled_date, payment_type, …)
1149 * @param array<string, string> $extra failure_reason, balance_after_formatted, permanent_failure, …
1150 * @return array<string, string>
1151 */
1152 public static function variablesFromScheduledPayment(object $booking, object $scheduledRow, array $extra = []): array
1153 {
1154 $currency = (string) ($scheduledRow->currency ?? $booking->currency ?? SettingsService::getCurrency());
1155 $amount = (float) ($scheduledRow->amount ?? 0);
1156 $schedDate = !empty($scheduledRow->scheduled_date)
1157 ? date_i18n(get_option('date_format'), strtotime((string) $scheduledRow->scheduled_date))
1158 : '';
1159
1160 $base = self::variablesFromBooking($booking);
1161 $base['scheduled_amount_formatted'] = yatra_format_price($amount, $currency);
1162 $base['scheduled_date_formatted'] = $schedDate;
1163 $base['payment_type_label'] = (string) ($scheduledRow->payment_type ?? '');
1164 $base['scheduled_payment_id'] = (string) (int) ($scheduledRow->id ?? 0);
1165
1166 return array_merge($base, $extra);
1167 }
1168 }
1169