PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5.1
Yatra – Travel Booking & Tour Operator Software v3.0.5.1
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
yatra / app / Services / TransactionalEmailTemplateService.php

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

930 lines 41.3 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 public const TYPE_BOOKING_CANCELLATION = 'booking_cancellation';
18
19 public const TYPE_BOOKING_REMINDER = 'booking_reminder';
20
21 public const TYPE_ADMIN_NEW_BOOKING = 'admin_new_booking';
22
23 public const TYPE_ADMIN_PAYMENT_RECEIVED = 'admin_payment_received';
24
25 public const TYPE_ADMIN_BOOKING_CANCELLED = 'admin_booking_cancelled_notice';
26
27 /** Trip consent request (Yatra Pro Trip Consent module). */
28 public const TYPE_TRIP_CONSENT_REQUEST = 'trip_consent_request';
29
30 /** Customer account email verification (e.g. checkout registration). */
31 public const TYPE_CUSTOMER_EMAIL_VERIFICATION = 'customer_email_verification';
32
33 /**
34 * Guest-checkout email verification — sent BEFORE payment when
35 * `require_guest_email_verification` is on. The booking is held
36 * in `pending_verification` status until the customer clicks the
37 * magic link. Distinct from `customer_email_verification` because
38 * (a) the recipient is not a registered user, and (b) the link
39 * resumes the in-flight booking flow rather than completing
40 * account registration.
41 */
42 public const TYPE_GUEST_EMAIL_VERIFICATION = 'guest_email_verification';
43
44 public const TYPE_BOOKING_COMPLETED = 'booking_completed';
45
46 public const TYPE_BOOKING_EXPIRED_CUSTOMER = 'booking_expired_customer';
47
48 public const TYPE_ADMIN_BOOKING_EXPIRED = 'admin_booking_expired';
49
50 public const TYPE_SCHEDULED_PAYMENT_REMINDER = 'scheduled_payment_reminder';
51
52 public const TYPE_SCHEDULED_PAYMENT_SUCCEEDED = 'scheduled_payment_succeeded';
53
54 public const TYPE_SCHEDULED_PAYMENT_FAILED = 'scheduled_payment_failed';
55
56 public const TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED = 'admin_scheduled_payment_failed';
57
58 public const TYPE_ENQUIRY_ADMIN = 'enquiry_admin';
59
60 public const TYPE_ENQUIRY_CUSTOMER_RECEIVED = 'enquiry_received';
61
62 public const TYPE_ENQUIRY_CUSTOMER_RESPONSE = 'enquiry_response';
63
64 public const TYPE_REVIEW_REQUEST = 'review_request';
65
66 /** Abandoned checkout recovery (Yatra Pro); 3-stage sequence. */
67 public const TYPE_ABANDONED_BOOKING_RECOVERY_FIRST = 'abandoned_booking_recovery_first';
68 public const TYPE_ABANDONED_BOOKING_RECOVERY_SECOND = 'abandoned_booking_recovery_second';
69 public const TYPE_ABANDONED_BOOKING_RECOVERY_FINAL = 'abandoned_booking_recovery_final';
70
71 /**
72 * Map catalog / settings UI keys to internal render types.
73 */
74 public static function coreTemplateKeyToType(string $templateKey): ?string
75 {
76 $map = [
77 'booking_confirmation' => self::TYPE_BOOKING_CONFIRMATION,
78 'payment_received' => self::TYPE_PAYMENT_CONFIRMATION,
79 'booking_cancelled' => self::TYPE_BOOKING_CANCELLATION,
80 'trip_reminder' => self::TYPE_BOOKING_REMINDER,
81 'admin_new_booking' => self::TYPE_ADMIN_NEW_BOOKING,
82 'admin_payment_received' => self::TYPE_ADMIN_PAYMENT_RECEIVED,
83 'admin_booking_cancelled' => self::TYPE_ADMIN_BOOKING_CANCELLED,
84 'trip_consent_request' => self::TYPE_TRIP_CONSENT_REQUEST,
85 'customer_email_verification' => self::TYPE_CUSTOMER_EMAIL_VERIFICATION,
86 'guest_email_verification' => self::TYPE_GUEST_EMAIL_VERIFICATION,
87 'booking_completed' => self::TYPE_BOOKING_COMPLETED,
88 'booking_expired_customer' => self::TYPE_BOOKING_EXPIRED_CUSTOMER,
89 'admin_booking_expired' => self::TYPE_ADMIN_BOOKING_EXPIRED,
90 'scheduled_payment_reminder' => self::TYPE_SCHEDULED_PAYMENT_REMINDER,
91 'scheduled_payment_succeeded' => self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED,
92 'scheduled_payment_failed' => self::TYPE_SCHEDULED_PAYMENT_FAILED,
93 'admin_scheduled_payment_failed' => self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED,
94 'enquiry_admin' => self::TYPE_ENQUIRY_ADMIN,
95 'enquiry_received' => self::TYPE_ENQUIRY_CUSTOMER_RECEIVED,
96 'enquiry_response' => self::TYPE_ENQUIRY_CUSTOMER_RESPONSE,
97 'review_request' => self::TYPE_REVIEW_REQUEST,
98 'abandoned_booking_recovery_first' => self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST,
99 'abandoned_booking_recovery_second' => self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND,
100 'abandoned_booking_recovery_final' => self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL,
101 ];
102
103 return $map[$templateKey] ?? null;
104 }
105
106 /**
107 * Sample merge-tag values for admin preview (core templates).
108 *
109 * @return array<string, string>
110 */
111 public static function sampleVariablesForCoreTemplateKey(string $templateKey): array
112 {
113 return EmailTemplateSampleData::forTemplateKey($templateKey);
114 }
115
116 /**
117 * @param array<string, string|int|float> $variables
118 * @return array<string, string>
119 */
120 public static function mergeTemplateVariables(array $variables): array
121 {
122 return self::mergeDefaultVariables($variables);
123 }
124
125 /**
126 * Replace {{word}} placeholders (used by previews and extensions).
127 *
128 * @param array<string, string|int|float> $variables
129 */
130 public static function parseMergeTags(string $template, array $variables): string
131 {
132 $variables = self::mergeDefaultVariables($variables);
133
134 return self::parseTemplate($template, $variables);
135 }
136
137 /**
138 * Render using explicit subject/body templates (e.g. unsaved editor content). Empty strings use built-in defaults.
139 *
140 * @param array<string, string|int|float> $variables
141 * @return array{subject: string, body: string}
142 */
143 public static function renderWithStringTemplates(string $type, string $subjectTpl, string $bodyTpl, array $variables): array
144 {
145 $variables = self::mergeDefaultVariables($variables);
146 $variables = self::normalizeVariablesForType($type, $variables);
147 $map = self::typeToSettingsKeys();
148 if (!isset($map[$type])) {
149 return ['subject' => '', 'body' => ''];
150 }
151
152 if ($subjectTpl === '') {
153 $subject = self::defaultSubject($type, $variables);
154 } else {
155 $subject = self::parseTemplate($subjectTpl, $variables);
156 }
157
158 if ($bodyTpl === '') {
159 $body = self::defaultBody($type, $variables);
160 } else {
161 $body = self::parseTemplate($bodyTpl, $variables);
162 }
163
164 return [
165 'subject' => $subject,
166 'body' => $body,
167 ];
168 }
169
170 /**
171 * @return array<string, string>
172 */
173 private static function typeToSettingsKeys(): array
174 {
175 $defaults = [
176 self::TYPE_BOOKING_CONFIRMATION => [
177 'flag' => 'email_template_booking',
178 'subject' => 'email_tpl_booking_subject',
179 'body' => 'email_tpl_booking_body',
180 ],
181 self::TYPE_PAYMENT_CONFIRMATION => [
182 'flag' => 'email_template_confirmation',
183 'subject' => 'email_tpl_payment_subject',
184 'body' => 'email_tpl_payment_body',
185 ],
186 self::TYPE_BOOKING_CANCELLATION => [
187 'flag' => 'email_template_cancellation',
188 'subject' => 'email_tpl_cancellation_subject',
189 'body' => 'email_tpl_cancellation_body',
190 ],
191 self::TYPE_BOOKING_REMINDER => [
192 'flag' => 'email_template_reminder',
193 'subject' => 'email_tpl_reminder_subject',
194 'body' => 'email_tpl_reminder_body',
195 ],
196 self::TYPE_ADMIN_NEW_BOOKING => [
197 'flag' => 'email_template_admin_new_booking',
198 'subject' => 'email_tpl_admin_booking_subject',
199 'body' => 'email_tpl_admin_booking_body',
200 ],
201 self::TYPE_ADMIN_PAYMENT_RECEIVED => [
202 'flag' => 'email_template_admin_payment',
203 'subject' => 'email_tpl_admin_payment_subject',
204 'body' => 'email_tpl_admin_payment_body',
205 ],
206 self::TYPE_ADMIN_BOOKING_CANCELLED => [
207 'flag' => 'email_template_admin_cancellation',
208 'subject' => 'email_tpl_admin_cancellation_subject',
209 'body' => 'email_tpl_admin_cancellation_body',
210 ],
211 self::TYPE_TRIP_CONSENT_REQUEST => [
212 'flag' => 'email_template_trip_consent',
213 'subject' => 'email_tpl_trip_consent_subject',
214 'body' => 'email_tpl_trip_consent_body',
215 ],
216 self::TYPE_CUSTOMER_EMAIL_VERIFICATION => [
217 'flag' => 'email_template_customer_verification',
218 'subject' => 'email_tpl_customer_verification_subject',
219 'body' => 'email_tpl_customer_verification_body',
220 ],
221 self::TYPE_GUEST_EMAIL_VERIFICATION => [
222 'flag' => 'email_template_guest_verification',
223 'subject' => 'email_tpl_guest_verification_subject',
224 'body' => 'email_tpl_guest_verification_body',
225 ],
226 self::TYPE_BOOKING_COMPLETED => [
227 'flag' => 'email_template_booking_completed',
228 'subject' => 'email_tpl_booking_completed_subject',
229 'body' => 'email_tpl_booking_completed_body',
230 ],
231 self::TYPE_BOOKING_EXPIRED_CUSTOMER => [
232 'flag' => 'email_template_booking_expired_customer',
233 'subject' => 'email_tpl_booking_expired_customer_subject',
234 'body' => 'email_tpl_booking_expired_customer_body',
235 ],
236 self::TYPE_ADMIN_BOOKING_EXPIRED => [
237 'flag' => 'email_template_admin_booking_expired',
238 'subject' => 'email_tpl_admin_booking_expired_subject',
239 'body' => 'email_tpl_admin_booking_expired_body',
240 ],
241 self::TYPE_SCHEDULED_PAYMENT_REMINDER => [
242 'flag' => 'email_template_scheduled_payment_reminder',
243 'subject' => 'email_tpl_scheduled_payment_reminder_subject',
244 'body' => 'email_tpl_scheduled_payment_reminder_body',
245 ],
246 self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED => [
247 'flag' => 'email_template_scheduled_payment_succeeded',
248 'subject' => 'email_tpl_scheduled_payment_succeeded_subject',
249 'body' => 'email_tpl_scheduled_payment_succeeded_body',
250 ],
251 self::TYPE_SCHEDULED_PAYMENT_FAILED => [
252 'flag' => 'email_template_scheduled_payment_failed',
253 'subject' => 'email_tpl_scheduled_payment_failed_subject',
254 'body' => 'email_tpl_scheduled_payment_failed_body',
255 ],
256 self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED => [
257 'flag' => 'email_template_admin_scheduled_payment_failed',
258 'subject' => 'email_tpl_admin_scheduled_payment_failed_subject',
259 'body' => 'email_tpl_admin_scheduled_payment_failed_body',
260 ],
261 self::TYPE_ENQUIRY_ADMIN => [
262 'flag' => 'email_template_enquiry_admin',
263 'subject' => 'email_tpl_enquiry_admin_subject',
264 'body' => 'email_tpl_enquiry_admin_body',
265 ],
266 self::TYPE_ENQUIRY_CUSTOMER_RECEIVED => [
267 'flag' => 'email_template_enquiry_received',
268 'subject' => 'email_tpl_enquiry_received_subject',
269 'body' => 'email_tpl_enquiry_received_body',
270 ],
271 self::TYPE_ENQUIRY_CUSTOMER_RESPONSE => [
272 'flag' => 'email_template_enquiry_response',
273 'subject' => 'email_tpl_enquiry_response_subject',
274 'body' => 'email_tpl_enquiry_response_body',
275 ],
276 self::TYPE_REVIEW_REQUEST => [
277 'flag' => 'email_template_review_request',
278 'subject' => 'email_tpl_review_request_subject',
279 'body' => 'email_tpl_review_request_body',
280 ],
281 self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST => [
282 'flag' => 'email_template_abandoned_booking_recovery_first',
283 'subject' => 'email_tpl_abandoned_booking_recovery_first_subject',
284 'body' => 'email_tpl_abandoned_booking_recovery_first_body',
285 ],
286 self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND => [
287 'flag' => 'email_template_abandoned_booking_recovery_second',
288 'subject' => 'email_tpl_abandoned_booking_recovery_second_subject',
289 'body' => 'email_tpl_abandoned_booking_recovery_second_body',
290 ],
291 self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL => [
292 'flag' => 'email_template_abandoned_booking_recovery_final',
293 'subject' => 'email_tpl_abandoned_booking_recovery_final_subject',
294 'body' => 'email_tpl_abandoned_booking_recovery_final_body',
295 ],
296 ];
297
298 /**
299 * Allow Pro modules (Team & Access, etc.) to register additional
300 * transactional template types — each entry must be an array with
301 * `flag`, `subject`, `body` keys matching the option-name pattern
302 * used above. Once registered, the type participates in:
303 * - sendIfEnabled() (flag gate + send)
304 * - render() / renderWithStringTemplates() (templated subject/body)
305 * - the Email → Templates UI (auto-discovered via the same map)
306 *
307 * Modules also need to hook `yatra_transactional_email_default_subject`
308 * and `..._default_body` to supply baseline copy for their type.
309 *
310 * @param array<string, array{flag:string,subject:string,body:string}> $defaults
311 */
312 return (array) apply_filters('yatra_transactional_email_type_to_keys', $defaults);
313 }
314
315 /**
316 * Send if the type is enabled in settings. Pro may handle via {@see 'yatra_send_transactional_email'}.
317 *
318 * Optional string `transactional_context` (e.g. `booking_created`, `status_confirmed`) is passed through
319 * to the filter so Pro can choose a different template row for the same TYPE_BOOKING_CONFIRMATION.
320 *
321 * @param array<string, string|int|float> $variables Merge tags: {{key}}
322 */
323 public static function sendIfEnabled(string $type, string $to, array $variables = []): bool
324 {
325 $to = sanitize_email($to);
326 if ($to === '' || !is_email($to)) {
327 return false;
328 }
329
330 $map = self::typeToSettingsKeys();
331 if (!isset($map[$type])) {
332 return false;
333 }
334
335 $flag = $map[$type]['flag'];
336 $proOwnsType = (bool) apply_filters('yatra_pro_email_automation_owns_transactional_type', false, $type);
337
338 if (!$proOwnsType && !SettingsService::isEnabled($flag)) {
339 return false;
340 }
341
342 $variables = self::mergeDefaultVariables($variables);
343 $variables = self::normalizeVariablesForType($type, $variables);
344
345 /**
346 * Allow Yatra Pro (or extensions) to send instead of core templates.
347 * Return null to use core; true/false if handled.
348 */
349 $handled = apply_filters('yatra_send_transactional_email', null, $type, $to, $variables);
350 if ($handled !== null) {
351 return (bool) $handled;
352 }
353
354 if (!SettingsService::isEnabled($flag)) {
355 return false;
356 }
357
358 $rendered = self::render($type, $variables);
359
360 return EmailService::send(
361 $to,
362 $rendered['subject'],
363 $rendered['body'],
364 ['Content-Type: text/html; charset=UTF-8']
365 );
366 }
367
368 /**
369 * @param array<string, string|int|float> $variables
370 * @return array{subject: string, body: string}
371 */
372 public static function render(string $type, array $variables): array
373 {
374 $map = self::typeToSettingsKeys();
375 if (!isset($map[$type])) {
376 return ['subject' => '', 'body' => ''];
377 }
378
379 $subjectKey = $map[$type]['subject'];
380 $bodyKey = $map[$type]['body'];
381
382 $subjectTpl = SettingsService::getString($subjectKey, '');
383 $bodyTpl = SettingsService::getString($bodyKey, '');
384
385 return self::renderWithStringTemplates($type, $subjectTpl, $bodyTpl, $variables);
386 }
387
388 /**
389 * @param array<string, string|int|float> $variables
390 * @return array<string, string>
391 */
392 private static function mergeDefaultVariables(array $variables): array
393 {
394 $defaults = [
395 'site_name' => get_bloginfo('name'),
396 'site_url' => home_url('/'),
397 'admin_email' => SettingsService::getString('admin_email', get_option('admin_email')),
398 ];
399
400 $out = [];
401 foreach (array_merge($defaults, $variables) as $k => $v) {
402 $out[(string) $k] = is_scalar($v) ? (string) $v : '';
403 }
404
405 return $out;
406 }
407
408 /**
409 * Ensure templates always have safe, meaningful defaults for commonly-used tags.
410 *
411 * This prevents "blank sections" when a caller supplies only the core booking variables
412 * (e.g. status-change emails) while the template contains richer optional sections.
413 *
414 * @param array<string, string> $variables
415 * @return array<string, string>
416 */
417 private static function normalizeVariablesForType(string $type, array $variables): array
418 {
419 // Booking confirmation is sent from multiple contexts (checkout + admin status changes).
420 // If the caller didn't include the rich "intro/details/footer" blocks, provide a minimal,
421 // data-driven fallback so the email still looks correct.
422 if ($type === self::TYPE_BOOKING_CONFIRMATION) {
423 if (!isset($variables['intro_paragraph']) || trim($variables['intro_paragraph']) === '') {
424 $variables['intro_paragraph'] = __('Thank you for your booking.', 'yatra');
425 }
426 if (!isset($variables['details_html']) || trim($variables['details_html']) === '') {
427 $variables['details_html'] = self::fallbackBookingDetailsHtml($variables);
428 }
429 if (!isset($variables['footer_note']) || trim($variables['footer_note']) === '') {
430 /* translators: %s: site name. */
431 $variables['footer_note'] = sprintf(__('— %s', 'yatra'), get_bloginfo('name'));
432 }
433 }
434
435 // Shared defaults that are safe for most templates if included.
436 if (!isset($variables['intro_paragraph'])) {
437 $variables['intro_paragraph'] = '';
438 }
439 if (!isset($variables['footer_note'])) {
440 $variables['footer_note'] = '';
441 }
442 if (!isset($variables['details_html'])) {
443 $variables['details_html'] = '';
444 }
445
446 return $variables;
447 }
448
449 /**
450 * Minimal booking details block for confirmation emails when caller doesn't provide `details_html`.
451 *
452 * @param array<string, string> $v
453 */
454 private static function fallbackBookingDetailsHtml(array $v): string
455 {
456 $trip = $v['trip_name'] ?? '';
457 $date = $v['travel_date'] ?? '';
458 $pax = $v['travelers_count'] ?? '';
459 $total = $v['total_amount_formatted'] ?? '';
460 $due = $v['amount_due_formatted'] ?? '';
461
462 $rows = [];
463 if ($trip !== '') {
464 $rows[] = ['label' => __('Trip', 'yatra'), 'value' => esc_html($trip)];
465 }
466 if ($date !== '') {
467 $rows[] = ['label' => __('Departure', 'yatra'), 'value' => esc_html($date)];
468 }
469 if ($pax !== '') {
470 $rows[] = ['label' => __('Travelers', 'yatra'), 'value' => esc_html($pax)];
471 }
472 if ($total !== '') {
473 $rows[] = ['label' => __('Total', 'yatra'), 'value' => esc_html($total)];
474 }
475 if ($due !== '') {
476 $rows[] = ['label' => __('Amount due', 'yatra'), 'value' => esc_html($due)];
477 }
478
479 if (empty($rows)) {
480 return '';
481 }
482
483 return EmailTemplateLayout::detailCard($rows);
484 }
485
486 /**
487 * @param array<string, string> $variables
488 */
489 private static function parseTemplate(string $template, array $variables): string
490 {
491 $rendered = (string) preg_replace_callback(
492 // Allow optional whitespace: {{trip_name}} and {{ trip_name }} both work.
493 '/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/',
494 static function (array $m) use ($variables): string {
495 $key = $m[1];
496
497 // Never leak raw merge-tags into real emails. If a variable is
498 // missing, replace it with an empty string rather than
499 // returning the original {{tag}} token.
500 return $variables[$key] ?? '';
501 },
502 $template
503 );
504
505 // Hard-strip any remaining merge-tags (defense-in-depth).
506 $rendered = (string) preg_replace('/\{\{\s*[a-zA-Z0-9_]+\s*\}\}/', '', $rendered);
507
508 // Users sometimes paste helper text from the editor into the template.
509 // If that happens, strip common helper headings so they don't appear in
510 // production emails.
511 $rendered = (string) preg_replace(
512 '/^.*(Available Variables|Available placeholders|Available Placeholders|Merge tags).*$/mi',
513 '',
514 $rendered
515 );
516
517 return $rendered;
518 }
519
520 /**
521 * @param array<string, string> $v
522 */
523 private static function defaultSubject(string $type, array $v): string
524 {
525 $site = $v['site_name'] ?? get_bloginfo('name');
526 $ref = $v['booking_reference'] ?? $v['booking_id'] ?? '';
527
528 switch ($type) {
529 case self::TYPE_BOOKING_CONFIRMATION:
530 /* translators: 1: site name, 2: booking reference. */
531 return sprintf(__('✈️ [%1$s] Booking update · %2$s', 'yatra'), $site, $ref);
532
533 case self::TYPE_PAYMENT_CONFIRMATION:
534 /* translators: 1: site name, 2: booking reference. */
535 return sprintf(__('�
536 [%1$s] Payment received · %2$s', 'yatra'), $site, $ref);
537
538 case self::TYPE_BOOKING_CANCELLATION:
539 /* translators: 1: site name, 2: booking reference. */
540 return sprintf(__('📋 [%1$s] Booking cancelled · %2$s', 'yatra'), $site, $ref);
541
542 case self::TYPE_BOOKING_REMINDER:
543 /* translators: 1: site name, 2: booking reference. */
544 return sprintf(__('🗓️ [%1$s] Your trip is coming up · %2$s', 'yatra'), $site, $ref);
545
546 case self::TYPE_ADMIN_NEW_BOOKING:
547 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
548 return sprintf(__('🔔 [%1$s] New booking · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
549
550 case self::TYPE_ADMIN_PAYMENT_RECEIVED:
551 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
552 return sprintf(__('�
553 [%1$s] Payment received · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
554
555 case self::TYPE_ADMIN_BOOKING_CANCELLED:
556 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
557 return sprintf(__('📋 [%1$s] Booking cancelled · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
558
559 case self::TYPE_TRIP_CONSENT_REQUEST:
560 $formName = $v['form_name'] ?? __('consent form', 'yatra');
561
562 /* translators: 1: site name, 2: consent form name. */
563 return sprintf(__('📝 [%1$s] Action required · %2$s', 'yatra'), $site, $formName);
564
565 case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
566 /* translators: %s: site name. */
567 return sprintf(__('✉️ [%s] Verify your email address', 'yatra'), $site);
568
569 case self::TYPE_GUEST_EMAIL_VERIFICATION:
570 // Distinct subject so customers can tell apart "verify
571 // your account" from "verify to complete your booking".
572 /* translators: %s: site name. */
573 return sprintf(__('✉️ [%s] Verify your email to complete your booking', 'yatra'), $site);
574
575 case self::TYPE_BOOKING_COMPLETED:
576 /* translators: 1: site name, 2: booking reference. */
577 return sprintf(__('🌟 [%1$s] Trip complete · %2$s', 'yatra'), $site, $ref);
578
579 case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
580 /* translators: 1: site name, 2: booking reference. */
581 return sprintf(__('⏱️ [%1$s] Booking expired · %2$s', 'yatra'), $site, $ref);
582
583 case self::TYPE_ADMIN_BOOKING_EXPIRED:
584 /* translators: 1: site name, 2: booking reference, 3: booking ID. */
585 return sprintf(__('⏱️ [%1$s] Booking expired · %2$s (#%3$s)', 'yatra'), $site, $ref, $v['booking_id'] ?? '');
586
587 case self::TYPE_SCHEDULED_PAYMENT_REMINDER:
588 /* translators: 1: site name, 2: booking reference. */
589 return sprintf(__('💳 [%1$s] Upcoming payment · %2$s', 'yatra'), $site, $ref);
590
591 case self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED:
592 /* translators: 1: site name, 2: booking reference. */
593 return sprintf(__('�
594 [%1$s] Scheduled payment received · %2$s', 'yatra'), $site, $ref);
595
596 case self::TYPE_SCHEDULED_PAYMENT_FAILED:
597 /* translators: 1: site name, 2: booking reference. */
598 return sprintf(__('⚠️ [%1$s] Payment issue · %2$s', 'yatra'), $site, $ref);
599
600 case self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED:
601 /* translators: 1: site name, 2: booking reference. */
602 return sprintf(__('⚠️ [%1$s] Scheduled payment failed · %2$s', 'yatra'), $site, $ref);
603
604 case self::TYPE_ENQUIRY_ADMIN:
605 $who = $v['customer_name'] ?? __('Customer', 'yatra');
606
607 /* translators: 1: site name, 2: customer name. */
608 return sprintf(__('💬 [%1$s] New enquiry · %2$s', 'yatra'), $site, $who);
609
610 case self::TYPE_ENQUIRY_CUSTOMER_RECEIVED:
611 /* translators: %s: site name. */
612 return sprintf(__('✉️ [%s] We received your message', 'yatra'), $site);
613
614 case self::TYPE_ENQUIRY_CUSTOMER_RESPONSE:
615 /* translators: %s: site name. */
616 return sprintf(__('💬 [%s] Re: your enquiry', 'yatra'), $site);
617
618 case self::TYPE_REVIEW_REQUEST:
619 $trip = $v['trip_name'] ?? __('your trip', 'yatra');
620
621 /* translators: 1: site name, 2: trip name. */
622 return sprintf(__('⭐ [%1$s] How was %2$s?', 'yatra'), $site, $trip);
623
624 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
625 /* translators: %s: site name. */
626 return sprintf(__('🛒 [%s] Complete your booking', 'yatra'), $site);
627
628 case self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND:
629 /* translators: %s: site name. */
630 return sprintf(__('⏳ [%s] Still interested? Your booking is waiting', 'yatra'), $site);
631
632 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL:
633 /* translators: %s: site name. */
634 return sprintf(__('⚠️ [%s] Final reminder: complete your booking', 'yatra'), $site);
635
636 default:
637 // Pro modules register their own types via
638 // `yatra_transactional_email_type_to_keys` — they
639 // supply default copy through this filter. Returning
640 // empty string means "no extension claimed this type"
641 // and we fall back to the generic notification line.
642 $custom = (string) apply_filters(
643 'yatra_transactional_email_default_subject',
644 '',
645 $type,
646 $v
647 );
648 if ($custom !== '') {
649 return $custom;
650 }
651 /* translators: %s: site name. */
652 return sprintf(__('✉️ [%s] Notification', 'yatra'), $site);
653 }
654 }
655
656 /**
657 * @param array<string, string> $v
658 */
659 private static function defaultBody(string $type, array $v): string
660 {
661 switch ($type) {
662 case self::TYPE_BOOKING_CONFIRMATION:
663 return EmailTemplateDefaults::fallbackTransactionalBooking($v);
664
665 case self::TYPE_PAYMENT_CONFIRMATION:
666 return EmailTemplateDefaults::fallbackTransactionalPayment($v);
667
668 case self::TYPE_BOOKING_CANCELLATION:
669 return EmailTemplateDefaults::fallbackTransactionalCancellation($v);
670
671 case self::TYPE_BOOKING_REMINDER:
672 return EmailTemplateDefaults::fallbackTransactionalReminder($v);
673
674 case self::TYPE_ADMIN_NEW_BOOKING:
675 return EmailTemplateDefaults::fallbackAdminNewBooking($v);
676
677 case self::TYPE_ADMIN_PAYMENT_RECEIVED:
678 return EmailTemplateDefaults::fallbackAdminPaymentReceived($v);
679
680 case self::TYPE_ADMIN_BOOKING_CANCELLED:
681 return EmailTemplateDefaults::fallbackAdminBookingCancelled($v);
682
683 case self::TYPE_TRIP_CONSENT_REQUEST:
684 return EmailTemplateDefaults::fallbackTransactionalTripConsent($v);
685
686 case self::TYPE_CUSTOMER_EMAIL_VERIFICATION:
687 return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v);
688
689 case self::TYPE_GUEST_EMAIL_VERIFICATION:
690 // Reuse the customer-verification body. The flow is
691 // similar — click a magic link to prove ownership of
692 // the address — and operators that have already
693 // customised the customer-verification copy get a
694 // consistent look across both. Differentiating copy is
695 // injected at call-time via the intro_paragraph /
696 // footer_note merge tags by the booking handler.
697 return EmailTemplateDefaults::fallbackTransactionalCustomerEmailVerification($v);
698
699 case self::TYPE_BOOKING_COMPLETED:
700 return EmailTemplateDefaults::fallbackTransactionalBookingCompleted($v);
701
702 case self::TYPE_BOOKING_EXPIRED_CUSTOMER:
703 return EmailTemplateDefaults::fallbackTransactionalBookingExpiredCustomer($v);
704
705 case self::TYPE_ADMIN_BOOKING_EXPIRED:
706 return EmailTemplateDefaults::fallbackAdminBookingExpired($v);
707
708 case self::TYPE_SCHEDULED_PAYMENT_REMINDER:
709 return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentReminder($v);
710
711 case self::TYPE_SCHEDULED_PAYMENT_SUCCEEDED:
712 return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentSucceeded($v);
713
714 case self::TYPE_SCHEDULED_PAYMENT_FAILED:
715 return EmailTemplateDefaults::fallbackTransactionalScheduledPaymentFailed($v);
716
717 case self::TYPE_ADMIN_SCHEDULED_PAYMENT_FAILED:
718 return EmailTemplateDefaults::fallbackAdminScheduledPaymentFailed($v);
719
720 case self::TYPE_ENQUIRY_ADMIN:
721 return EmailTemplateDefaults::fallbackTransactionalEnquiryAdmin($v);
722
723 case self::TYPE_ENQUIRY_CUSTOMER_RECEIVED:
724 return EmailTemplateDefaults::fallbackTransactionalEnquiryReceived($v);
725
726 case self::TYPE_ENQUIRY_CUSTOMER_RESPONSE:
727 return EmailTemplateDefaults::fallbackTransactionalEnquiryResponse($v);
728
729 case self::TYPE_REVIEW_REQUEST:
730 return EmailTemplateDefaults::fallbackTransactionalReviewRequest($v);
731
732 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FIRST:
733 return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoveryFirst($v);
734
735 case self::TYPE_ABANDONED_BOOKING_RECOVERY_SECOND:
736 return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoverySecond($v);
737
738 case self::TYPE_ABANDONED_BOOKING_RECOVERY_FINAL:
739 return EmailTemplateDefaults::fallbackTransactionalAbandonedBookingRecoveryFinal($v);
740
741 default:
742 // Pro modules register their own types via
743 // `yatra_transactional_email_type_to_keys` — they
744 // supply default body markup through this filter.
745 // Returning empty string falls back to the generic
746 // notification block.
747 $custom = (string) apply_filters(
748 'yatra_transactional_email_default_body',
749 '',
750 $type,
751 $v
752 );
753 if ($custom !== '') {
754 return $custom;
755 }
756 return EmailTemplateLayout::customer(
757 '✉️',
758 __('Notification', 'yatra'),
759 '<p style="margin:0;color:#475569;">' . esc_html__('This is an automated message from your travel site.', 'yatra') . '</p>',
760 esc_html($v['site_name'] ?? get_bloginfo('name'))
761 );
762 }
763 }
764
765 /**
766 * Build variables from a booking row (admin / cron).
767 *
768 * Includes rich tags from {@see BookingEmailRichMergeTags::forBooking()}:
769 * `payment_gateway`, `payment_gateway_label`, `payment_schedule`, `payment_schedule_label`,
770 * `travelers_list`, `travelers_list_html`, `traveler_custom_fields_html`, `booking_custom_fields_html`,
771 * `special_requests`, `special_requests_html`.
772 *
773 * Note: `{{payment_method}}` on payment emails is the instrument label (e.g. Card) merged by callers;
774 * gateway/slug labels use `payment_gateway` / `payment_gateway_label`. Deposit vs full uses `payment_schedule*`.
775 *
776 * @return array<string, string> Filter: `yatra_booking_email_variables`.
777 */
778 public static function variablesFromBooking(object $booking): array
779 {
780 $currency = $booking->currency ?? SettingsService::getCurrency();
781 $travelDate = !empty($booking->travel_date)
782 ? date_i18n(get_option('date_format'), strtotime((string) $booking->travel_date))
783 : '';
784
785 $bookingId = (int) ($booking->id ?? 0);
786 $base = [
787 'customer_name' => trim((string) (($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''))),
788 'customer_first_name' => (string) ($booking->contact_first_name ?? ''),
789 'customer_last_name' => (string) ($booking->contact_last_name ?? ''),
790 'customer_email' => (string) ($booking->contact_email ?? ''),
791 'customer_phone' => (string) ($booking->contact_phone ?? ''),
792 'booking_reference' => (string) ($booking->reference ?? ''),
793 'booking_id' => (string) $bookingId,
794 'booking_url' => $bookingId > 0 ? home_url('/my-account/bookings/' . $bookingId) : home_url('/'),
795 'trip_name' => (string) ($booking->trip_title ?? ''),
796 'trip_url' => !empty($booking->trip_slug)
797 ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode((string) $booking->trip_slug) . '/')
798 : home_url('/'),
799 'travel_date' => $travelDate,
800 'travelers_count' => (string) (int) ($booking->travelers_count ?? 0),
801 'total_amount_formatted' => yatra_format_price((float) ($booking->total_amount ?? 0)),
802 'amount_due_formatted' => yatra_format_price((float) ($booking->amount_due ?? 0)),
803 // Aliases for the legacy / customer-customised template
804 // syntax: many templates (including ones edited via Settings
805 // → Email Templates) reference `{{total_amount}}` and
806 // `{{balance_due}}` directly rather than the
807 // `_formatted` variants. Without these aliases the
808 // placeholders survived unsubstituted into the rendered
809 // email body. Aliases use the same formatted-with-currency
810 // value as the canonical keys above so templates remain
811 // visually consistent regardless of which name is used.
812 'total_amount' => yatra_format_price((float) ($booking->total_amount ?? 0)),
813 'balance_due' => yatra_format_price((float) ($booking->amount_due ?? 0)),
814 'amount_due' => yatra_format_price((float) ($booking->amount_due ?? 0)),
815 'amount_paid' => yatra_format_price((float) ($booking->amount_paid ?? 0)),
816 'amount_paid_formatted' => yatra_format_price((float) ($booking->amount_paid ?? 0)),
817 'currency' => $currency,
818 'booking_status' => (string) ($booking->status ?? ''),
819 'payment_status' => (string) ($booking->payment_status ?? ''),
820 'admin_url' => admin_url('admin.php?page=yatra'),
821 ];
822
823 $rich = BookingEmailRichMergeTags::forBooking($booking);
824
825 /** @var array<string, string> $merged */
826 $merged = array_merge($base, $rich);
827
828 return apply_filters('yatra_booking_email_variables', $merged, $booking);
829 }
830
831 /**
832 * Merge tags for enquiry emails (row from EnquiryRepository::findWithTrip()).
833 *
834 * @param object $enquiry Row with name, email, phone, message, trip_title, etc.
835 * @return array<string, string>
836 */
837 public static function variablesFromEnquiry(object $enquiry, string $responsePlain = ''): array
838 {
839 $trip = trim((string) ($enquiry->trip_title ?? ''));
840 $tripSlug = (string) ($enquiry->trip_slug ?? '');
841 $tripId = isset($enquiry->trip_id) ? (int) $enquiry->trip_id : 0;
842
843 // Defense-in-depth: if repository join didn't provide trip_title/slug but we do have a trip_id,
844 // resolve the trip directly so {{trip_name}} doesn't fall back to "General enquiry".
845 if (($trip === '' || $tripSlug === '') && $tripId > 0) {
846 try {
847 $repo = new \Yatra\Repositories\TripRepository();
848 $tripRow = $repo->find($tripId);
849 if ($tripRow) {
850 if ($trip === '' && !empty($tripRow->title)) {
851 $trip = trim((string) $tripRow->title);
852 }
853 if ($tripSlug === '' && !empty($tripRow->slug)) {
854 $tripSlug = (string) $tripRow->slug;
855 }
856 }
857 } catch (\Throwable $e) {
858 // Ignore: keep existing values/fallback.
859 }
860 }
861
862 $tripUrl = $tripSlug !== ''
863 ? home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode($tripSlug) . '/')
864 : home_url('/');
865
866 $created = (string) ($enquiry->created_at ?? '');
867 $enquiryDate = $created !== ''
868 ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($created) ?: time())
869 : '';
870
871 $vars = [
872 'customer_name' => (string) ($enquiry->name ?? ''),
873 'customer_email' => (string) ($enquiry->email ?? ''),
874 'customer_phone' => (string) ($enquiry->phone ?? ''),
875 'enquiry_id' => (string) (int) ($enquiry->id ?? 0),
876 'enquiry_date' => $enquiryDate,
877 'subject' => (string) ($enquiry->subject ?? ''),
878 'trip_name' => $trip !== '' ? $trip : __('General enquiry', 'yatra'),
879 'trip_url' => $tripUrl,
880 'message' => nl2br(esc_html((string) ($enquiry->message ?? ''))),
881 'original_message' => (string) ($enquiry->message ?? ''),
882 ];
883
884 // Response-only tags are injected solely on the response email so the
885 // sidebar for `enquiry.created` doesn't surface tags that would render
886 // empty in that context.
887 if ($responsePlain !== '') {
888 $responseHtml = nl2br(esc_html($responsePlain));
889 $vars['response'] = $responseHtml;
890 $vars['response_message'] = $responseHtml;
891 $vars['response_date'] = date_i18n(get_option('date_format') . ' ' . get_option('time_format'));
892 }
893
894 return $vars;
895 }
896
897 /**
898 * @param object $booking Booking row (contact_*, reference, …)
899 * @param array<string, string> $extra e.g. expiry_hours, expiry_notice_html
900 * @return array<string, string>
901 */
902 public static function variablesFromBookingWithExtras(object $booking, array $extra = []): array
903 {
904 return array_merge(self::variablesFromBooking($booking), $extra);
905 }
906
907 /**
908 * @param object $booking Booking row
909 * @param object $scheduledRow Scheduled payment row (amount, currency, scheduled_date, payment_type, …)
910 * @param array<string, string> $extra failure_reason, balance_after_formatted, permanent_failure, …
911 * @return array<string, string>
912 */
913 public static function variablesFromScheduledPayment(object $booking, object $scheduledRow, array $extra = []): array
914 {
915 $currency = (string) ($scheduledRow->currency ?? $booking->currency ?? SettingsService::getCurrency());
916 $amount = (float) ($scheduledRow->amount ?? 0);
917 $schedDate = !empty($scheduledRow->scheduled_date)
918 ? date_i18n(get_option('date_format'), strtotime((string) $scheduledRow->scheduled_date))
919 : '';
920
921 $base = self::variablesFromBooking($booking);
922 $base['scheduled_amount_formatted'] = yatra_format_price($amount, $currency);
923 $base['scheduled_date_formatted'] = $schedDate;
924 $base['payment_type_label'] = (string) ($scheduledRow->payment_type ?? '');
925 $base['scheduled_payment_id'] = (string) (int) ($scheduledRow->id ?? 0);
926
927 return array_merge($base, $extra);
928 }
929 }
930